fix: redact sensitive tokens in tool summaries
This commit is contained in:
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