fix: Finish credential redaction that was merged unfinished (#13073)

* Squash

* Removed unused files

Not mine, someone merged that stuff in earlier.

* fix: patch redaction regressions and schema breakages

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Henry Loenwind
2026-02-13 16:19:21 +01:00
committed by GitHub
parent faec6ccb1d
commit 96318641d8
18 changed files with 1641 additions and 1291 deletions

View File

@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import type { ConfigUiHints } from "./schema.js";
import type { ConfigFileSnapshot } from "./types.openclaw.js";
import {
REDACTED_SENTINEL,
redactConfigSnapshot,
restoreRedactedValues,
restoreRedactedValues as restoreRedactedValues_orig,
} from "./redact-snapshot.js";
import { __test__ } from "./schema.hints.js";
import { OpenClawSchema } from "./zod-schema.js";
const { mapSensitivePaths } = __test__;
function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFileSnapshot {
return {
@@ -22,6 +27,16 @@ function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFile
};
}
function restoreRedactedValues(
incoming: unknown,
original: unknown,
hints?: ConfigUiHints,
): unknown {
var result = restoreRedactedValues_orig(incoming, original, hints);
expect(result.ok).toBe(true);
return result.result;
}
describe("redactConfigSnapshot", () => {
it("redacts top-level token fields", () => {
const snapshot = makeSnapshot({
@@ -217,6 +232,25 @@ describe("redactConfigSnapshot", () => {
expect(result.parsed).toBeNull();
});
it("withholds resolved config for invalid snapshots", () => {
const snapshot: ConfigFileSnapshot = {
path: "/test",
exists: true,
raw: '{ "gateway": { "auth": { "token": "leaky-secret" } } }',
parsed: { gateway: { auth: { token: "leaky-secret" } } },
resolved: { gateway: { auth: { token: "leaky-secret" } } } as ConfigFileSnapshot["resolved"],
valid: false,
config: {} as ConfigFileSnapshot["config"],
issues: [{ path: "", message: "invalid config" }],
warnings: [],
legacyIssues: [],
};
const result = redactConfigSnapshot(snapshot);
expect(result.raw).toBeNull();
expect(result.parsed).toBeNull();
expect(result.resolved).toEqual({});
});
it("handles deeply nested tokens in accounts", () => {
const snapshot = makeSnapshot({
channels: {
@@ -259,35 +293,379 @@ describe("redactConfigSnapshot", () => {
});
const result = redactConfigSnapshot(snapshot);
const env = result.config.env as Record<string, Record<string, string>>;
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
// NODE_ENV is not sensitive, should be preserved
expect(env.vars.NODE_ENV).toBe("production");
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
});
it("redacts raw by key pattern even when parsed config is empty", () => {
const snapshot: ConfigFileSnapshot = {
path: "/test",
exists: true,
raw: '{ token: "raw-secret-1234567890" }',
parsed: {},
valid: false,
config: {} as ConfigFileSnapshot["config"],
issues: [],
warnings: [],
legacyIssues: [],
};
const result = redactConfigSnapshot(snapshot);
expect(result.raw).not.toContain("raw-secret-1234567890");
expect(result.raw).toContain(REDACTED_SENTINEL);
});
it("redacts sensitive fields even when the value is not a string", () => {
it("does NOT redact numeric 'tokens' fields (token regex fix)", () => {
const snapshot = makeSnapshot({
gateway: { auth: { token: 1234 } },
memory: { tokens: 8192 },
});
const result = redactConfigSnapshot(snapshot);
const memory = result.config.memory as Record<string, number>;
expect(memory.tokens).toBe(8192);
});
it("does NOT redact 'softThresholdTokens' (token regex fix)", () => {
const snapshot = makeSnapshot({
compaction: { softThresholdTokens: 50000 },
});
const result = redactConfigSnapshot(snapshot);
const compaction = result.config.compaction as Record<string, number>;
expect(compaction.softThresholdTokens).toBe(50000);
});
it("does NOT redact string 'tokens' field either", () => {
const snapshot = makeSnapshot({
memory: { tokens: "should-not-be-redacted" },
});
const result = redactConfigSnapshot(snapshot);
const memory = result.config.memory as Record<string, string>;
expect(memory.tokens).toBe("should-not-be-redacted");
});
it("still redacts 'token' (singular) fields", () => {
const snapshot = makeSnapshot({
channels: { slack: { token: "secret-slack-token-value-here" } },
});
const result = redactConfigSnapshot(snapshot);
const channels = result.config.channels as Record<string, Record<string, string>>;
expect(channels.slack.token).toBe(REDACTED_SENTINEL);
});
it("uses uiHints to determine sensitivity", () => {
const hints: ConfigUiHints = {
"custom.mySecret": { sensitive: true },
};
const snapshot = makeSnapshot({
custom: { mySecret: "this-is-a-custom-secret-value" },
});
const result = redactConfigSnapshot(snapshot, hints);
const custom = result.config.custom as Record<string, string>;
expect(custom.mySecret).toBe(REDACTED_SENTINEL);
});
it("keeps regex fallback for extension keys not covered by uiHints", () => {
const hints: ConfigUiHints = {
"plugins.entries.voice-call.config": { label: "Voice Call Config" },
"channels.my-channel": { label: "My Channel" },
};
const snapshot = makeSnapshot({
plugins: {
entries: {
"voice-call": {
config: {
apiToken: "voice-call-secret-token",
displayName: "Voice call extension",
},
},
},
},
channels: {
"my-channel": {
accessToken: "my-channel-secret-token",
room: "general",
},
},
});
const redacted = redactConfigSnapshot(snapshot, hints);
expect(redacted.config.plugins.entries["voice-call"].config.apiToken).toBe(REDACTED_SENTINEL);
expect(redacted.config.plugins.entries["voice-call"].config.displayName).toBe(
"Voice call extension",
);
expect(redacted.config.channels["my-channel"].accessToken).toBe(REDACTED_SENTINEL);
expect(redacted.config.channels["my-channel"].room).toBe("general");
const restored = restoreRedactedValues(redacted.config, snapshot.config, hints);
expect(restored).toEqual(snapshot.config);
});
it("honors sensitive:false for extension keys even with regex fallback", () => {
const hints: ConfigUiHints = {
"plugins.entries.voice-call.config": { label: "Voice Call Config" },
"plugins.entries.voice-call.config.apiToken": { sensitive: false },
};
const snapshot = makeSnapshot({
plugins: {
entries: {
"voice-call": {
config: {
apiToken: "not-secret-on-purpose",
},
},
},
},
});
const redacted = redactConfigSnapshot(snapshot, hints);
expect(redacted.config.plugins.entries["voice-call"].config.apiToken).toBe(
"not-secret-on-purpose",
);
});
it("handles nested values properly (roundtrip)", () => {
const snapshot = makeSnapshot({
custom1: { anykey: { mySecret: "this-is-a-custom-secret-value" } },
custom2: [{ mySecret: "this-is-a-custom-secret-value" }],
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.custom1.anykey.mySecret).toBe(REDACTED_SENTINEL);
expect(result.config.custom2[0].mySecret).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.custom1.anykey.mySecret).toBe("this-is-a-custom-secret-value");
expect(restored.custom2[0].mySecret).toBe("this-is-a-custom-secret-value");
});
it("handles nested values properly with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"custom1.*.mySecret": { sensitive: true },
"custom2[].mySecret": { sensitive: true },
};
const snapshot = makeSnapshot({
custom1: { anykey: { mySecret: "this-is-a-custom-secret-value" } },
custom2: [{ mySecret: "this-is-a-custom-secret-value" }],
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.custom1.anykey.mySecret).toBe(REDACTED_SENTINEL);
expect(result.config.custom2[0].mySecret).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.custom1.anykey.mySecret).toBe("this-is-a-custom-secret-value");
expect(restored.custom2[0].mySecret).toBe("this-is-a-custom-secret-value");
});
it("handles records that are directly sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
custom: { token: "this-is-a-custom-secret-value", mySecret: "this-is-a-custom-secret-value" },
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.custom.token).toBe(REDACTED_SENTINEL);
expect(result.config.custom.mySecret).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.custom.token).toBe("this-is-a-custom-secret-value");
expect(restored.custom.mySecret).toBe("this-is-a-custom-secret-value");
});
it("handles records that are directly sensitive with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"custom.*": { sensitive: true },
};
const snapshot = makeSnapshot({
custom: {
anykey: "this-is-a-custom-secret-value",
mySecret: "this-is-a-custom-secret-value",
},
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.custom.anykey).toBe(REDACTED_SENTINEL);
expect(result.config.custom.mySecret).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.custom.anykey).toBe("this-is-a-custom-secret-value");
expect(restored.custom.mySecret).toBe("this-is-a-custom-secret-value");
});
it("handles arrays that are directly sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.token[0]).toBe(REDACTED_SENTINEL);
expect(result.config.token[1]).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.token[0]).toBe("this-is-a-custom-secret-value");
expect(restored.token[1]).toBe("this-is-a-custom-secret-value");
});
it("handles arrays that are directly sensitive with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"custom[]": { sensitive: true },
};
const snapshot = makeSnapshot({
custom: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.custom[0]).toBe(REDACTED_SENTINEL);
expect(result.config.custom[1]).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.custom[0]).toBe("this-is-a-custom-secret-value");
expect(restored.custom[1]).toBe("this-is-a-custom-secret-value");
});
it("handles arrays that are not sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
harmless: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-looking-value"],
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.harmless[0]).toBe("this-is-a-custom-harmless-value");
expect(result.config.harmless[1]).toBe("this-is-a-custom-secret-looking-value");
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.harmless[0]).toBe("this-is-a-custom-harmless-value");
expect(restored.harmless[1]).toBe("this-is-a-custom-secret-looking-value");
});
it("handles arrays that are not sensitive with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"custom[]": { sensitive: false },
};
const snapshot = makeSnapshot({
custom: ["this-is-a-custom-harmless-value", "this-is-a-custom-secret-value"],
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.custom[0]).toBe("this-is-a-custom-harmless-value");
expect(result.config.custom[1]).toBe("this-is-a-custom-secret-value");
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.custom[0]).toBe("this-is-a-custom-harmless-value");
expect(restored.custom[1]).toBe("this-is-a-custom-secret-value");
});
it("handles deep arrays that are directly sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
nested: {
level: {
token: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
},
},
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.nested.level.token[0]).toBe(REDACTED_SENTINEL);
expect(result.config.nested.level.token[1]).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.nested.level.token[0]).toBe("this-is-a-custom-secret-value");
expect(restored.nested.level.token[1]).toBe("this-is-a-custom-secret-value");
});
it("handles deep arrays that are directly sensitive with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"nested.level.custom[]": { sensitive: true },
};
const snapshot = makeSnapshot({
nested: {
level: {
custom: ["this-is-a-custom-secret-value", "this-is-a-custom-secret-value"],
},
},
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.nested.level.custom[0]).toBe(REDACTED_SENTINEL);
expect(result.config.nested.level.custom[1]).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.nested.level.custom[0]).toBe("this-is-a-custom-secret-value");
expect(restored.nested.level.custom[1]).toBe("this-is-a-custom-secret-value");
});
it("handles deep non-string arrays that are directly sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
nested: {
level: {
token: [42, 815],
},
},
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.nested.level.token[0]).toBe(42);
expect(result.config.nested.level.token[1]).toBe(815);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.nested.level.token[0]).toBe(42);
expect(restored.nested.level.token[1]).toBe(815);
});
it("handles deep non-string arrays that are directly sensitive with hints (roundtrip)", () => {
const hints: ConfigUiHints = {
"nested.level.custom[]": { sensitive: true },
};
const snapshot = makeSnapshot({
nested: {
level: {
custom: [42, 815],
},
},
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.nested.level.custom[0]).toBe(42);
expect(result.config.nested.level.custom[1]).toBe(815);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.nested.level.custom[0]).toBe(42);
expect(restored.nested.level.custom[1]).toBe(815);
});
it("handles deep arrays that are upstream sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
nested: {
password: {
harmless: ["value", "value"],
},
},
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.nested.password.harmless[0]).toBe(REDACTED_SENTINEL);
expect(result.config.nested.password.harmless[1]).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.nested.password.harmless[0]).toBe("value");
expect(restored.nested.password.harmless[1]).toBe("value");
});
it("handles deep arrays that are not sensitive (roundtrip)", () => {
const snapshot = makeSnapshot({
nested: {
level: {
harmless: ["value", "value"],
},
},
});
const result = redactConfigSnapshot(snapshot);
expect(result.config.nested.level.harmless[0]).toBe("value");
expect(result.config.nested.level.harmless[1]).toBe("value");
const restored = restoreRedactedValues(result.config, snapshot.config);
expect(restored.nested.level.harmless[0]).toBe("value");
expect(restored.nested.level.harmless[1]).toBe("value");
});
it("respects sensitive:false in uiHints even for regex-matching paths", () => {
const hints: ConfigUiHints = {
"gateway.auth.token": { sensitive: false },
};
const snapshot = makeSnapshot({
gateway: { auth: { token: "not-actually-secret-value" } },
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
expect(gw.auth.token).toBe("not-actually-secret-value");
});
it("does not redact paths absent from uiHints (schema is single source of truth)", () => {
const hints: ConfigUiHints = {
"some.other.path": { sensitive: true },
};
const snapshot = makeSnapshot({
gateway: { auth: { password: "not-in-hints-value" } },
});
const result = redactConfigSnapshot(snapshot, hints);
const gw = result.config.gateway as Record<string, Record<string, string>>;
expect(gw.auth.password).toBe("not-in-hints-value");
});
it("uses wildcard hints for array items", () => {
const hints: ConfigUiHints = {
"channels.slack.accounts[].botToken": { sensitive: true },
};
const snapshot = makeSnapshot({
channels: {
slack: {
accounts: [
{ botToken: "first-account-token-value-here" },
{ botToken: "second-account-token-value-here" },
],
},
},
});
const result = redactConfigSnapshot(snapshot, hints);
const channels = result.config.channels as Record<
string,
Record<string, Array<Record<string, string>>>
>;
expect(channels.slack.accounts[0].botToken).toBe(REDACTED_SENTINEL);
expect(channels.slack.accounts[1].botToken).toBe(REDACTED_SENTINEL);
});
});
@@ -360,12 +738,12 @@ describe("restoreRedactedValues", () => {
channels: { newChannel: { token: REDACTED_SENTINEL } },
};
const original = {};
expect(() => restoreRedactedValues(incoming, original)).toThrow(/redacted/i);
expect(restoreRedactedValues_orig(incoming, original).ok).toBe(false);
});
it("handles null and undefined inputs", () => {
expect(restoreRedactedValues(null, { token: "x" })).toBeNull();
expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined();
expect(restoreRedactedValues_orig(null, { token: "x" }).ok).toBe(false);
expect(restoreRedactedValues_orig(undefined, { token: "x" }).ok).toBe(false);
});
it("round-trips config through redact → restore", () => {
@@ -398,4 +776,110 @@ describe("restoreRedactedValues", () => {
expect(restored).toEqual(originalConfig);
});
it("round-trips with uiHints for custom sensitive fields", () => {
const hints: ConfigUiHints = {
"custom.myApiKey": { sensitive: true },
"custom.displayName": { sensitive: false },
};
const originalConfig = {
custom: { myApiKey: "secret-custom-api-key-value", displayName: "My Bot" },
};
const snapshot = makeSnapshot(originalConfig);
const redacted = redactConfigSnapshot(snapshot, hints);
const custom = redacted.config.custom as Record<string, string>;
expect(custom.myApiKey).toBe(REDACTED_SENTINEL);
expect(custom.displayName).toBe("My Bot");
const restored = restoreRedactedValues(
redacted.config,
snapshot.config,
hints,
) as typeof originalConfig;
expect(restored).toEqual(originalConfig);
});
it("restores with uiHints respecting sensitive:false override", () => {
const hints: ConfigUiHints = {
"gateway.auth.token": { sensitive: false },
};
const incoming = {
gateway: { auth: { token: REDACTED_SENTINEL } },
};
const original = {
gateway: { auth: { token: "real-secret" } },
};
// With sensitive:false, the sentinel is NOT on a sensitive path,
// so restore should NOT replace it (it's treated as a literal value)
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL);
});
it("restores array items using wildcard uiHints", () => {
const hints: ConfigUiHints = {
"channels.slack.accounts[].botToken": { sensitive: true },
};
const incoming = {
channels: {
slack: {
accounts: [
{ botToken: REDACTED_SENTINEL },
{ botToken: "user-provided-new-token-value" },
],
},
},
};
const original = {
channels: {
slack: {
accounts: [
{ botToken: "original-token-first-account" },
{ botToken: "original-token-second-account" },
],
},
},
};
const result = restoreRedactedValues(incoming, original, hints) as typeof incoming;
expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account");
expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value");
});
});
describe("realredactConfigSnapshot_real", () => {
it("main schema redact works (samples)", () => {
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = mapSensitivePaths(OpenClawSchema, "", {});
const snapshot = makeSnapshot({
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: "1234",
},
},
},
list: [
{
memorySearch: {
remote: {
apiKey: "6789",
},
},
},
],
},
});
const result = redactConfigSnapshot(snapshot, hints);
expect(result.config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
expect(result.config.agents.list[0].memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234");
expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789");
});
});