fix: redact sensitive tokens in tool summaries
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||||
|
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||||
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
|
||||||
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||||
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
|||||||
- Console output can be tuned separately via:
|
- Console output can be tuned separately via:
|
||||||
- `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`)
|
- `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`)
|
||||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||||
|
- Tool summaries can be redacted to avoid leaking secrets:
|
||||||
|
- `logging.redactSensitive` (`off` | `tools`, default: `tools`)
|
||||||
|
- `logging.redactPatterns` (array of regex strings; overrides defaults)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -148,7 +151,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
|||||||
level: "info",
|
level: "info",
|
||||||
file: "/tmp/clawdbot/clawdbot.log",
|
file: "/tmp/clawdbot/clawdbot.log",
|
||||||
consoleLevel: "info",
|
consoleLevel: "info",
|
||||||
consoleStyle: "pretty"
|
consoleStyle: "pretty",
|
||||||
|
redactSensitive: "tools",
|
||||||
|
redactPatterns: [
|
||||||
|
// Example: override defaults with your own rules.
|
||||||
|
"\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1",
|
||||||
|
"/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ You can tune console verbosity independently via:
|
|||||||
- `logging.consoleLevel` (default `info`)
|
- `logging.consoleLevel` (default `info`)
|
||||||
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
- `logging.consoleStyle` (`pretty` | `compact` | `json`)
|
||||||
|
|
||||||
|
## Tool summary redaction
|
||||||
|
|
||||||
|
Verbose tool summaries (e.g. `🛠️ bash: ...`) can mask sensitive tokens before they hit the
|
||||||
|
console stream. This is **tools-only** and does not alter file logs.
|
||||||
|
|
||||||
|
- `logging.redactSensitive`: `off` | `tools` (default: `tools`)
|
||||||
|
- `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.
|
||||||
|
|
||||||
## Gateway WebSocket logs
|
## Gateway WebSocket logs
|
||||||
|
|
||||||
The gateway prints WebSocket protocol logs in two modes:
|
The gateway prints WebSocket protocol logs in two modes:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { redactToolDetail } from "../logging/redact.js";
|
||||||
import { shortenHomeInString } from "../utils.js";
|
import { shortenHomeInString } from "../utils.js";
|
||||||
|
|
||||||
type ToolDisplayActionSpec = {
|
type ToolDisplayActionSpec = {
|
||||||
@@ -193,7 +194,7 @@ export function resolveToolDisplay(params: {
|
|||||||
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
export function formatToolDetail(display: ToolDisplay): string | undefined {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (display.verb) parts.push(display.verb);
|
if (display.verb) parts.push(display.verb);
|
||||||
if (display.detail) parts.push(display.detail);
|
if (display.detail) parts.push(redactToolDetail(display.detail));
|
||||||
if (parts.length === 0) return undefined;
|
if (parts.length === 0) return undefined;
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,19 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||||
|
const logging = cfg.logging;
|
||||||
|
if (!logging) return cfg;
|
||||||
|
if (logging.redactSensitive) return cfg;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
logging: {
|
||||||
|
...logging,
|
||||||
|
redactSensitive: "tools",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resetSessionDefaultsWarningForTests() {
|
export function resetSessionDefaultsWarningForTests() {
|
||||||
defaultWarnState = { warned: false };
|
defaultWarnState = { warned: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../infra/shell-env.js";
|
} from "../infra/shell-env.js";
|
||||||
import {
|
import {
|
||||||
applyIdentityDefaults,
|
applyIdentityDefaults,
|
||||||
|
applyLoggingDefaults,
|
||||||
applyModelAliasDefaults,
|
applyModelAliasDefaults,
|
||||||
applySessionDefaults,
|
applySessionDefaults,
|
||||||
applyTalkApiKey,
|
applyTalkApiKey,
|
||||||
@@ -115,7 +116,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
}
|
}
|
||||||
const cfg = applyModelAliasDefaults(
|
const cfg = applyModelAliasDefaults(
|
||||||
applySessionDefaults(
|
applySessionDefaults(
|
||||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
applyLoggingDefaults(
|
||||||
|
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,7 +204,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
parsed: parsedRes.parsed,
|
parsed: parsedRes.parsed,
|
||||||
valid: true,
|
valid: true,
|
||||||
config: applyTalkApiKey(
|
config: applyTalkApiKey(
|
||||||
applyModelAliasDefaults(applySessionDefaults(validated.config)),
|
applyModelAliasDefaults(
|
||||||
|
applySessionDefaults(applyLoggingDefaults(validated.config)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
issues: [],
|
issues: [],
|
||||||
legacyIssues,
|
legacyIssues,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export type LoggingConfig = {
|
|||||||
| "debug"
|
| "debug"
|
||||||
| "trace";
|
| "trace";
|
||||||
consoleStyle?: "pretty" | "compact" | "json";
|
consoleStyle?: "pretty" | "compact" | "json";
|
||||||
|
/** Redact sensitive tokens in tool summaries. Default: "tools". */
|
||||||
|
redactSensitive?: "off" | "tools";
|
||||||
|
/** Regex patterns used to redact sensitive tokens (defaults apply when unset). */
|
||||||
|
redactPatterns?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebReconnectConfig = {
|
export type WebReconnectConfig = {
|
||||||
|
|||||||
@@ -330,6 +330,8 @@ export const ClawdbotSchema = z.object({
|
|||||||
consoleStyle: z
|
consoleStyle: z
|
||||||
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
|
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(),
|
||||||
|
redactPatterns: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
browser: z
|
browser: z
|
||||||
|
|||||||
99
src/logging/redact.test.ts
Normal file
99
src/logging/redact.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getDefaultRedactPatterns, redactSensitiveText } from "./redact.js";
|
||||||
|
|
||||||
|
const defaults = getDefaultRedactPatterns();
|
||||||
|
|
||||||
|
describe("redactSensitiveText", () => {
|
||||||
|
it("masks env assignments while keeping the key", () => {
|
||||||
|
const input = "OPENAI_API_KEY=sk-1234567890abcdef";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe("OPENAI_API_KEY=sk-123…cdef");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks CLI flags", () => {
|
||||||
|
const input = "curl --token abcdef1234567890ghij https://api.test";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe("curl --token abcdef…ghij https://api.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks JSON fields", () => {
|
||||||
|
const input = '{"token":"abcdef1234567890ghij"}';
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe('{"token":"abcdef…ghij"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks bearer tokens", () => {
|
||||||
|
const input = "Authorization: Bearer abcdef1234567890ghij";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe("Authorization: Bearer abcdef…ghij");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks Telegram-style tokens", () => {
|
||||||
|
const input = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe("123456…cdef");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts short tokens fully", () => {
|
||||||
|
const input = "TOKEN=shortvalue";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe("TOKEN=***");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts private key blocks", () => {
|
||||||
|
const input = [
|
||||||
|
"-----BEGIN PRIVATE KEY-----",
|
||||||
|
"ABCDEF1234567890",
|
||||||
|
"ZYXWVUT987654321",
|
||||||
|
"-----END PRIVATE KEY-----",
|
||||||
|
].join("\n");
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe(
|
||||||
|
[
|
||||||
|
"-----BEGIN PRIVATE KEY-----",
|
||||||
|
"…redacted…",
|
||||||
|
"-----END PRIVATE KEY-----",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors custom patterns with flags", () => {
|
||||||
|
const input = "token=abcdef1234567890ghij";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "tools",
|
||||||
|
patterns: ["/token=([A-Za-z0-9]+)/i"],
|
||||||
|
});
|
||||||
|
expect(output).toBe("token=abcdef…ghij");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips redaction when mode is off", () => {
|
||||||
|
const input = "OPENAI_API_KEY=sk-1234567890abcdef";
|
||||||
|
const output = redactSensitiveText(input, {
|
||||||
|
mode: "off",
|
||||||
|
patterns: defaults,
|
||||||
|
});
|
||||||
|
expect(output).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/logging/redact.ts
Normal file
128
src/logging/redact.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import type { LoggingConfig } from "../config/types.js";
|
||||||
|
|
||||||
|
export type RedactSensitiveMode = "off" | "tools";
|
||||||
|
|
||||||
|
const DEFAULT_REDACT_MODE: RedactSensitiveMode = "tools";
|
||||||
|
const DEFAULT_REDACT_MIN_LENGTH = 18;
|
||||||
|
const DEFAULT_REDACT_KEEP_START = 6;
|
||||||
|
const DEFAULT_REDACT_KEEP_END = 4;
|
||||||
|
|
||||||
|
const DEFAULT_REDACT_PATTERNS: string[] = [
|
||||||
|
// ENV-style assignments.
|
||||||
|
String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`,
|
||||||
|
// JSON fields.
|
||||||
|
String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`,
|
||||||
|
// CLI flags.
|
||||||
|
String.raw`--(?:api[-_]?key|token|secret|password|passwd)\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`,
|
||||||
|
// PEM blocks.
|
||||||
|
String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`,
|
||||||
|
// Common token prefixes.
|
||||||
|
String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`,
|
||||||
|
String.raw`\b(ghp_[A-Za-z0-9]{20,})\b`,
|
||||||
|
String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`,
|
||||||
|
String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`,
|
||||||
|
String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`,
|
||||||
|
String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`,
|
||||||
|
String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`,
|
||||||
|
String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`,
|
||||||
|
String.raw`\b(npm_[A-Za-z0-9]{10,})\b`,
|
||||||
|
String.raw`\b(\d{6,}:[A-Za-z0-9_-]{20,})\b`,
|
||||||
|
];
|
||||||
|
|
||||||
|
type RedactOptions = {
|
||||||
|
mode?: RedactSensitiveMode;
|
||||||
|
patterns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeMode(value?: string): RedactSensitiveMode {
|
||||||
|
return value === "off" ? "off" : DEFAULT_REDACT_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePattern(raw: string): RegExp | null {
|
||||||
|
if (!raw.trim()) return null;
|
||||||
|
const match = raw.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||||
|
try {
|
||||||
|
if (match) {
|
||||||
|
const flags = match[2].includes("g") ? match[2] : `${match[2]}g`;
|
||||||
|
return new RegExp(match[1], flags);
|
||||||
|
}
|
||||||
|
return new RegExp(raw, "gi");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePatterns(value?: string[]): RegExp[] {
|
||||||
|
const source = value?.length ? value : DEFAULT_REDACT_PATTERNS;
|
||||||
|
return source.map(parsePattern).filter((re): re is RegExp => Boolean(re));
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskToken(token: string): string {
|
||||||
|
if (token.length < DEFAULT_REDACT_MIN_LENGTH) return "***";
|
||||||
|
const start = token.slice(0, DEFAULT_REDACT_KEEP_START);
|
||||||
|
const end = token.slice(-DEFAULT_REDACT_KEEP_END);
|
||||||
|
return `${start}…${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactPemBlock(block: string): string {
|
||||||
|
const lines = block.split(/\r?\n/).filter(Boolean);
|
||||||
|
if (lines.length < 2) return "***";
|
||||||
|
return `${lines[0]}\n…redacted…\n${lines[lines.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactMatch(match: string, groups: string[]): string {
|
||||||
|
if (match.includes("PRIVATE KEY-----")) return redactPemBlock(match);
|
||||||
|
const token =
|
||||||
|
groups
|
||||||
|
.filter((value) => typeof value === "string" && value.length > 0)
|
||||||
|
.at(-1) ?? match;
|
||||||
|
const masked = maskToken(token);
|
||||||
|
if (token === match) return masked;
|
||||||
|
return match.replace(token, masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactText(text: string, patterns: RegExp[]): string {
|
||||||
|
let next = text;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
next = next.replace(
|
||||||
|
pattern,
|
||||||
|
(...args: string[]) =>
|
||||||
|
redactMatch(args[0], args.slice(1, args.length - 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigRedaction(): RedactOptions {
|
||||||
|
const cfg = loadConfig().logging;
|
||||||
|
return {
|
||||||
|
mode: normalizeMode(cfg?.redactSensitive),
|
||||||
|
patterns: cfg?.redactPatterns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactSensitiveText(
|
||||||
|
text: string,
|
||||||
|
options?: RedactOptions,
|
||||||
|
): string {
|
||||||
|
if (!text) return text;
|
||||||
|
const resolved = options ?? resolveConfigRedaction();
|
||||||
|
if (normalizeMode(resolved.mode) === "off") return text;
|
||||||
|
const patterns = resolvePatterns(resolved.patterns);
|
||||||
|
if (!patterns.length) return text;
|
||||||
|
return redactText(text, patterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redactToolDetail(detail: string): string {
|
||||||
|
const resolved = resolveConfigRedaction();
|
||||||
|
if (normalizeMode(resolved.mode) !== "tools") return detail;
|
||||||
|
return redactSensitiveText(detail, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultRedactPatterns(): string[] {
|
||||||
|
return [...DEFAULT_REDACT_PATTERNS];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user