security: redact credentials from config.get gateway responses (#9858)

* security: add skill/plugin code safety scanner module

* security: integrate skill scanner into security audit

* security: add pre-install code safety scan for plugins

* style: fix curly brace lint errors in skill-scanner.ts

* docs: add changelog entry for skill code safety scanner

* security: redact credentials from config.get gateway responses

The config.get gateway method returned the full config snapshot
including channel credentials (Discord tokens, Slack botToken/appToken,
Telegram botToken, Feishu appSecret, etc.), model provider API keys,
and gateway auth tokens in plaintext.

Any WebSocket client—including the unauthenticated Control UI when
dangerouslyDisableDeviceAuth is set—could read every secret.

This adds redactConfigSnapshot() which:
- Deep-walks the config object and masks any field whose key matches
  token, password, secret, or apiKey patterns
- Uses the existing redactSensitiveText() to scrub the raw JSON5 source
- Preserves the hash for change detection
- Includes 15 test cases covering all channel types

* security: make gateway config writes return redacted values

* test: disable control UI by default in gateway server tests

* fix: redact credentials in gateway config APIs (#9858) (thanks @abdelsfane)

---------

Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
Abdel Sy Fane
2026-02-05 17:34:48 -07:00
committed by GitHub
parent 5f6e1c19bd
commit 0c7fa2b0d5
7 changed files with 669 additions and 12 deletions

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveConfigSnapshotHash } from "../config/config.js";
import { CONFIG_PATH, resolveConfigSnapshotHash } from "../config/config.js";
import {
connectOk,
installGatewayTestHooks,
@@ -115,7 +115,82 @@ describe("gateway config.patch", () => {
}>(ws, (o) => o.type === "res" && o.id === get2Id);
expect(get2Res.ok).toBe(true);
expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("__OPENCLAW_REDACTED__");
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
const stored = JSON.parse(storedRaw) as {
channels?: { telegram?: { botToken?: string } };
};
expect(stored.channels?.telegram?.botToken).toBe("token-1");
});
it("preserves credentials on config.set when raw contains redacted sentinels", async () => {
const setId = "req-set-sentinel-1";
ws.send(
JSON.stringify({
type: "req",
id: setId,
method: "config.set",
params: {
raw: JSON.stringify({
gateway: { mode: "local" },
channels: { telegram: { botToken: "token-1" } },
}),
},
}),
);
const setRes = await onceMessage<{ ok: boolean }>(
ws,
(o) => o.type === "res" && o.id === setId,
);
expect(setRes.ok).toBe(true);
const getId = "req-get-sentinel-1";
ws.send(
JSON.stringify({
type: "req",
id: getId,
method: "config.get",
params: {},
}),
);
const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>(
ws,
(o) => o.type === "res" && o.id === getId,
);
expect(getRes.ok).toBe(true);
const baseHash = resolveConfigSnapshotHash({
hash: getRes.payload?.hash,
raw: getRes.payload?.raw,
});
expect(typeof baseHash).toBe("string");
const rawRedacted = getRes.payload?.raw;
expect(typeof rawRedacted).toBe("string");
expect(rawRedacted).toContain("__OPENCLAW_REDACTED__");
const set2Id = "req-set-sentinel-2";
ws.send(
JSON.stringify({
type: "req",
id: set2Id,
method: "config.set",
params: {
raw: rawRedacted,
baseHash,
},
}),
);
const set2Res = await onceMessage<{ ok: boolean }>(
ws,
(o) => o.type === "res" && o.id === set2Id,
);
expect(set2Res.ok).toBe(true);
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
const stored = JSON.parse(storedRaw) as {
channels?: { telegram?: { botToken?: string } };
};
expect(stored.channels?.telegram?.botToken).toBe("token-1");
});
it("writes config, stores sentinel, and schedules restart", async () => {