fix: make sensitive field whitelist case-insensitive (#16148)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: bb2d219e1f
Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Sk Akram
2026-02-15 21:01:48 +05:30
committed by GitHub
parent 6565ec2e53
commit 1911942363
5 changed files with 74 additions and 8 deletions

View File

@@ -179,6 +179,29 @@ describe("redactConfigSnapshot", () => {
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
});
it("does not redact passwordFile path fields", () => {
const snapshot = makeSnapshot({
channels: {
irc: {
passwordFile: "/etc/openclaw/irc-password.txt",
nickserv: {
passwordFile: "/etc/openclaw/nickserv-password.txt",
password: "super-secret-nickserv-password",
},
},
},
});
const result = redactConfigSnapshot(snapshot);
const channels = result.config.channels as Record<string, Record<string, unknown>>;
const irc = channels.irc;
const nickserv = irc.nickserv as Record<string, unknown>;
expect(irc.passwordFile).toBe("/etc/openclaw/irc-password.txt");
expect(nickserv.passwordFile).toBe("/etc/openclaw/nickserv-password.txt");
expect(nickserv.password).toBe(REDACTED_SENTINEL);
});
it("preserves hash unchanged", () => {
const snapshot = makeSnapshot({ gateway: { auth: { token: "secret-token-value-here" } } });
const result = redactConfigSnapshot(snapshot);
@@ -343,7 +366,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const custom = result.config.custom as Record<string, string>;
const resolved = result.resolved as Record<string, Record<string, string>>;
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
expect(resolved.custom.mySecret).toBe(REDACTED_SENTINEL);
});
it("keeps regex fallback for extension keys not covered by uiHints", () => {
@@ -630,7 +655,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.token).toBe("not-actually-secret-value");
expect(resolved.gateway.auth.token).toBe("not-actually-secret-value");
});
it("does not redact paths absent from uiHints (schema is single source of truth)", () => {
@@ -642,7 +669,9 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
const resolved = result.resolved as Record<string, Record<string, Record<string, string>>>;
expect(gw.auth.password).toBe("not-in-hints-value");
expect(resolved.gateway.auth.password).toBe("not-in-hints-value");
});
it("uses wildcard hints for array items", () => {

View File

@@ -301,7 +301,7 @@ export function redactConfigSnapshot(
const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config, uiHints) : null;
const redactedParsed = snapshot.parsed ? redactObject(snapshot.parsed, uiHints) : snapshot.parsed;
// Also redact the resolved config (contains values after ${ENV} substitution)
const redactedResolved = redactConfigObject(snapshot.resolved);
const redactedResolved = redactConfigObject(snapshot.resolved, uiHints);
return {
...snapshot,

View File

@@ -1,11 +1,38 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { __test__ } from "./schema.hints.js";
import { __test__, isSensitiveConfigPath } from "./schema.hints.js";
import { OpenClawSchema } from "./zod-schema.js";
import { sensitive } from "./zod-schema.sensitive.js";
const { mapSensitivePaths } = __test__;
describe("isSensitiveConfigPath", () => {
it("matches whitelist suffixes case-insensitively", () => {
const whitelistedPaths = [
"maxTokens",
"maxOutputTokens",
"maxInputTokens",
"maxCompletionTokens",
"contextTokens",
"totalTokens",
"tokenCount",
"tokenLimit",
"tokenBudget",
"channels.irc.nickserv.passwordFile",
];
for (const path of whitelistedPaths) {
expect(isSensitiveConfigPath(path)).toBe(false);
expect(isSensitiveConfigPath(path.toUpperCase())).toBe(false);
}
});
it("keeps true sensitive keys redacted", () => {
expect(isSensitiveConfigPath("channels.slack.token")).toBe(true);
expect(isSensitiveConfigPath("models.providers.openai.apiKey")).toBe(true);
expect(isSensitiveConfigPath("channels.irc.nickserv.password")).toBe(true);
});
});
describe("mapSensitivePaths", () => {
it("should detect sensitive fields nested inside all structural Zod types", () => {
const GrandSchema = z.object({

View File

@@ -91,7 +91,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
* These are explicitly excluded from redaction (plugin config) and
* warnings about not being marked sensitive (base config).
*/
const SENSITIVE_KEY_WHITELIST = new Set([
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
@@ -102,15 +102,24 @@ const SENSITIVE_KEY_WHITELIST = new Set([
"tokenlimit",
"tokenbudget",
"passwordFile",
]);
] as const;
const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFFIXES.map((suffix) =>
suffix.toLowerCase(),
);
const SENSITIVE_PATTERNS = [/token$/i, /password/i, /secret/i, /api.?key/i];
function isWhitelistedSensitivePath(path: string): boolean {
const lowerPath = path.toLowerCase();
return NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
}
function matchesSensitivePattern(path: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
export function isSensitiveConfigPath(path: string): boolean {
return (
!Array.from(SENSITIVE_KEY_WHITELIST).some((suffix) => path.endsWith(suffix)) &&
SENSITIVE_PATTERNS.some((pattern) => pattern.test(path))
);
return !isWhitelistedSensitivePath(path) && matchesSensitivePattern(path);
}
export function buildBaseHints(): ConfigUiHints {