diff --git a/src/security/audit-channel-discord-allowlists.test.ts b/src/security/audit-channel-discord-allowlists.test.ts new file mode 100644 index 00000000000..9f4d0667372 --- /dev/null +++ b/src/security/audit-channel-discord-allowlists.test.ts @@ -0,0 +1,297 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; +import { collectChannelSecurityFindings } from "./audit-channel.js"; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +function stubDiscordPlugin(): ChannelPlugin { + return { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/docs/testing", + blurb: "test stub", + }, + capabilities: { + chatTypes: ["direct", "group"], + }, + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + }, + security: { + collectAuditFindings: collectDiscordSecurityAuditFindings, + }, + config: { + listAccountIds: (cfg) => { + const ids = Object.keys(cfg.channels?.discord?.accounts ?? {}); + return ids.length > 0 ? ids : ["default"]; + }, + inspectAccount: (cfg, accountId) => { + const resolvedAccountId = + typeof accountId === "string" && accountId ? accountId : "default"; + const base = cfg.channels?.discord ?? {}; + const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {}; + return { + accountId: resolvedAccountId, + enabled: true, + configured: true, + token: "t", + tokenSource: "config", + config: { ...base, ...account }, + }; + }, + resolveAccount: (cfg, accountId) => { + const resolvedAccountId = + typeof accountId === "string" && accountId ? accountId : "default"; + const base = cfg.channels?.discord ?? {}; + const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {}; + return { + accountId: resolvedAccountId, + enabled: true, + token: "t", + tokenSource: "config", + config: { ...base, ...account }, + }; + }, + isEnabled: () => true, + isConfigured: () => true, + }, + }; +} + +describe("security audit discord allowlists", () => { + it.each([ + { + name: "warns when Discord allowlists contain name-based entries", + setup: async (tmp: string) => { + await fs.writeFile( + path.join(tmp, "credentials", "discord-allowFrom.json"), + JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), + ); + }, + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + allowFrom: ["Alice#1234", "<@123456789012345678>"], + guilds: { + "123": { + users: ["trusted.operator"], + channels: { + general: { + users: ["987654321098765432", "security-team"], + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectNameBasedSeverity: "warn", + detailIncludes: [ + "channels.discord.allowFrom:Alice#1234", + "channels.discord.guilds.123.users:trusted.operator", + "channels.discord.guilds.123.channels.general.users:security-team", + "~/.openclaw/credentials/discord-allowFrom.json:team.owner", + ], + detailExcludes: ["<@123456789012345678>"], + }, + { + name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice#1234"], + }, + }, + } satisfies OpenClawConfig, + expectNameBasedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + { + name: "audits non-default Discord accounts for dangerous name matching", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + accounts: { + alpha: { token: "a" }, + beta: { + token: "b", + dangerouslyAllowNameMatching: true, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectNoNameBasedFinding: true, + expectFindingMatch: { + checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", + title: expect.stringContaining("(account: beta)"), + severity: "info", + }, + }, + { + name: "audits name-based allowlists on non-default Discord accounts", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + accounts: { + alpha: { + token: "a", + allowFrom: ["123456789012345678"], + }, + beta: { + token: "b", + allowFrom: ["Alice#1234"], + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectNameBasedSeverity: "warn", + detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"], + }, + { + name: "does not warn when Discord allowlists use ID-style entries only", + cfg: { + channels: { + discord: { + enabled: true, + token: "t", + allowFrom: [ + "123456789012345678", + "<@223456789012345678>", + "user:323456789012345678", + "discord:423456789012345678", + "pk:member-123", + ], + guilds: { + "123": { + users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], + channels: { + general: { + users: ["723456789012345678", "user:823456789012345678"], + }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectNoNameBasedFinding: true, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async (tmp) => { + await testCase.setup?.(tmp); + readChannelAllowFromStoreMock.mockResolvedValue( + testCase.detailIncludes?.includes( + "~/.openclaw/credentials/discord-allowFrom.json:team.owner", + ) + ? ["team.owner"] + : [], + ); + const findings = await collectChannelSecurityFindings({ + cfg: testCase.cfg, + plugins: [stubDiscordPlugin()], + }); + const nameBasedFinding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", + ); + + if (testCase.expectNoNameBasedFinding) { + expect(nameBasedFinding).toBeUndefined(); + } else if ( + testCase.expectNameBasedSeverity || + testCase.detailIncludes?.length || + testCase.detailExcludes?.length + ) { + expect(nameBasedFinding).toBeDefined(); + if (testCase.expectNameBasedSeverity) { + expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity); + } + for (const snippet of testCase.detailIncludes ?? []) { + expect(nameBasedFinding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(nameBasedFinding?.detail).not.toContain(snippet); + } + } + + if (testCase.expectFindingMatch) { + const matchingFinding = findings.find( + (entry) => entry.checkId === testCase.expectFindingMatch.checkId, + ); + expect(matchingFinding).toEqual(expect.objectContaining(testCase.expectFindingMatch)); + } + }); + }); + + it("does not treat prototype properties as explicit Discord account config paths", async () => { + await withChannelSecurityStateDir(async () => { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "t", + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice#1234"], + accounts: {}, + }, + }, + }; + + readChannelAllowFromStoreMock.mockResolvedValue([]); + const pluginWithProtoDefaultAccount: ChannelPlugin = { + ...stubDiscordPlugin(), + config: { + ...stubDiscordPlugin().config, + listAccountIds: () => [], + defaultAccountId: () => "toString", + }, + }; + const findings = await collectChannelSecurityFindings({ + cfg, + plugins: [pluginWithProtoDefaultAccount], + }); + + const dangerousMatchingFinding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", + ); + expect(dangerousMatchingFinding).toBeDefined(); + expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)"); + + const nameBasedFinding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", + ); + expect(nameBasedFinding).toBeDefined(); + expect(nameBasedFinding?.detail).toContain("channels.discord.allowFrom:Alice#1234"); + expect(nameBasedFinding?.detail).not.toContain("channels.discord.accounts.toString"); + }); + }); +}); diff --git a/src/security/audit-channel-discord-command-findings.test.ts b/src/security/audit-channel-discord-command-findings.test.ts new file mode 100644 index 00000000000..f1802fa6d42 --- /dev/null +++ b/src/security/audit-channel-discord-command-findings.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js"; +import type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +function createDiscordAccount( + config: NonNullable["discord"], +): ResolvedDiscordAccount { + return { + accountId: "default", + enabled: true, + token: "t", + tokenSource: "config", + config, + }; +} + +describe("security audit discord command findings", () => { + it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { + const cfg: OpenClawConfig = { + commands: { native: true, useAccessGroups: false }, + channels: { + discord: { + enabled: true, + token: "t", + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { enabled: true }, + }, + }, + }, + }, + }, + }; + + await withChannelSecurityStateDir(async () => { + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectDiscordSecurityAuditFindings({ + cfg: cfg as OpenClawConfig & { + channels: { + discord: NonNullable["discord"]; + }; + }, + account: createDiscordAccount(cfg.channels!.discord), + accountId: "default", + orderedAccountIds: ["default"], + hasExplicitAccountPath: false, + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.discord.commands.native.unrestricted", + severity: "critical", + }), + ]), + ); + }); + }); +}); diff --git a/src/security/audit-channel-security.test-helpers.ts b/src/security/audit-channel-security.test-helpers.ts new file mode 100644 index 00000000000..c750e0e88ea --- /dev/null +++ b/src/security/audit-channel-security.test-helpers.ts @@ -0,0 +1,19 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { withEnvAsync } from "../test-utils/env.js"; + +export async function withChannelSecurityStateDir(fn: (tmp: string) => Promise) { + const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-channel-")); + const stateDir = path.join(fixtureRoot, "state"); + const credentialsDir = path.join(stateDir, "credentials"); + await fs.mkdir(credentialsDir, { + recursive: true, + mode: 0o700, + }); + try { + await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => fn(stateDir)); + } finally { + await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/src/security/audit-channel-slack-command-findings.test.ts b/src/security/audit-channel-slack-command-findings.test.ts new file mode 100644 index 00000000000..5c812323934 --- /dev/null +++ b/src/security/audit-channel-slack-command-findings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedSlackAccount } from "../../extensions/slack/src/accounts.js"; +import { collectSlackSecurityAuditFindings } from "../../extensions/slack/src/security-audit.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +function createSlackAccount( + config: NonNullable["slack"], +): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botToken: "xoxb-test", + botTokenSource: "config", + appTokenSource: "config", + config, + } as ResolvedSlackAccount; +} + +describe("security audit slack command findings", () => { + it("flags Slack slash commands without a channel users allowlist", async () => { + const cfg: OpenClawConfig = { + channels: { + slack: { + enabled: true, + botToken: "xoxb-test", + appToken: "xapp-test", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + + await withChannelSecurityStateDir(async () => { + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectSlackSecurityAuditFindings({ + cfg: cfg as OpenClawConfig & { + channels: { + slack: NonNullable["slack"]; + }; + }, + account: createSlackAccount(cfg.channels!.slack), + accountId: "default", + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.slack.commands.slash.no_allowlists", + severity: "warn", + }), + ]), + ); + }); + }); + + it("flags Slack slash commands when access-group enforcement is disabled", async () => { + const cfg: OpenClawConfig = { + commands: { useAccessGroups: false }, + channels: { + slack: { + enabled: true, + botToken: "xoxb-test", + appToken: "xapp-test", + groupPolicy: "open", + slashCommand: { enabled: true }, + }, + }, + }; + + await withChannelSecurityStateDir(async () => { + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectSlackSecurityAuditFindings({ + cfg: cfg as OpenClawConfig & { + channels: { + slack: NonNullable["slack"]; + }; + }, + account: createSlackAccount(cfg.channels!.slack), + accountId: "default", + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.slack.commands.slash.useAccessGroups_off", + severity: "critical", + }), + ]), + ); + }); + }); +}); diff --git a/src/security/audit-channel-synology-zalo.test.ts b/src/security/audit-channel-synology-zalo.test.ts new file mode 100644 index 00000000000..ccd1fdb3977 --- /dev/null +++ b/src/security/audit-channel-synology-zalo.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/contract-api.js"; +import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/contract-api.js"; +import type { ResolvedZalouserAccount } from "../../extensions/zalouser/src/accounts.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; +import { collectChannelSecurityFindings } from "./audit-channel.js"; + +function stubZalouserPlugin(): ChannelPlugin { + return { + id: "zalouser", + meta: { + id: "zalouser", + label: "Zalo Personal", + selectionLabel: "Zalo Personal", + docsPath: "/docs/testing", + blurb: "test stub", + }, + capabilities: { + chatTypes: ["direct", "group"], + }, + security: { + collectAuditFindings: collectZalouserSecurityAuditFindings, + }, + config: { + listAccountIds: () => ["default"], + inspectAccount: (cfg) => ({ + accountId: "default", + enabled: true, + configured: true, + config: cfg.channels?.zalouser ?? {}, + }), + resolveAccount: (cfg) => + ({ + accountId: "default", + enabled: true, + config: cfg.channels?.zalouser ?? {}, + }) as ResolvedZalouserAccount, + isEnabled: () => true, + isConfigured: () => true, + }, + }; +} + +describe("security audit synology and zalo channel routing", () => { + it.each([ + { + name: "audits Synology Chat base dangerous name matching", + cfg: { + channels: { + "synology-chat": { + token: "t", + incomingUrl: "https://nas.example.com/incoming", + dangerouslyAllowNameMatching: true, + }, + }, + } satisfies OpenClawConfig, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: "Synology Chat dangerous name matching is enabled", + }, + }, + { + name: "audits non-default Synology Chat accounts for dangerous name matching", + cfg: { + channels: { + "synology-chat": { + token: "t", + incomingUrl: "https://nas.example.com/incoming", + accounts: { + alpha: { + token: "a", + incomingUrl: "https://nas.example.com/incoming-alpha", + }, + beta: { + token: "b", + incomingUrl: "https://nas.example.com/incoming-beta", + dangerouslyAllowNameMatching: true, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: expect.stringContaining("(account: beta)"), + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const synologyChat = testCase.cfg.channels?.["synology-chat"]; + if (!synologyChat) { + throw new Error("synology-chat config required"); + } + const accountId = Object.keys(synologyChat.accounts ?? {}).includes("beta") + ? "beta" + : "default"; + const findings = collectSynologyChatSecurityAuditFindings({ + account: { + accountId, + enabled: true, + dangerouslyAllowNameMatching: + accountId === "beta" + ? synologyChat.accounts?.beta?.dangerouslyAllowNameMatching === true + : synologyChat.dangerouslyAllowNameMatching === true, + }, + accountId, + orderedAccountIds: Object.keys(synologyChat.accounts ?? {}), + hasExplicitAccountPath: accountId !== "default", + }); + expect(findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]), + ); + }); + }); + + it.each([ + { + name: "warns when Zalouser group routing contains mutable group entries", + cfg: { + channels: { + zalouser: { + enabled: true, + groups: { + "Ops Room": { allow: true }, + "group:g-123": { allow: true }, + }, + }, + }, + } satisfies OpenClawConfig, + expectedSeverity: "warn", + detailIncludes: ["channels.zalouser.groups:Ops Room"], + detailExcludes: ["group:g-123"], + }, + { + name: "marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", + cfg: { + channels: { + zalouser: { + enabled: true, + dangerouslyAllowNameMatching: true, + groups: { + "Ops Room": { allow: true }, + }, + }, + }, + } satisfies OpenClawConfig, + expectedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", + severity: "info", + }, + }, + ])("$name", async (testCase) => { + await withChannelSecurityStateDir(async () => { + const findings = await collectChannelSecurityFindings({ + cfg: testCase.cfg, + plugins: [stubZalouserPlugin()], + }); + const finding = findings.find( + (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", + ); + + expect(finding).toBeDefined(); + expect(finding?.severity).toBe(testCase.expectedSeverity); + for (const snippet of testCase.detailIncludes) { + expect(finding?.detail).toContain(snippet); + } + for (const snippet of testCase.detailExcludes ?? []) { + expect(finding?.detail).not.toContain(snippet); + } + if (testCase.expectFindingMatch) { + expect(findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), + ); + } + }); + }); +}); diff --git a/src/security/audit-channel-telegram-command-findings.test.ts b/src/security/audit-channel-telegram-command-findings.test.ts new file mode 100644 index 00000000000..0be928f1d2c --- /dev/null +++ b/src/security/audit-channel-telegram-command-findings.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/contract-api.js"; +import type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +function createTelegramAccount( + config: NonNullable["telegram"], +): ResolvedTelegramAccount { + return { + accountId: "default", + enabled: true, + tokenSource: "config", + config, + } as ResolvedTelegramAccount; +} + +describe("security audit telegram command findings", () => { + it("flags Telegram group commands without a sender allowlist", async () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + botToken: "t", + groupPolicy: "allowlist", + groups: { "-100123": {} }, + }, + }, + }; + + await withChannelSecurityStateDir(async () => { + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectTelegramSecurityAuditFindings({ + cfg: cfg as OpenClawConfig & { + channels: { + telegram: NonNullable["telegram"]; + }; + }, + account: createTelegramAccount(cfg.channels!.telegram), + accountId: "default", + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.telegram.groups.allowFrom.missing", + severity: "critical", + }), + ]), + ); + }); + }); + + it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + botToken: "t", + groupPolicy: "allowlist", + groupAllowFrom: ["@TrustedOperator"], + groups: { "-100123": {} }, + }, + }, + }; + + await withChannelSecurityStateDir(async () => { + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectTelegramSecurityAuditFindings({ + cfg: cfg as OpenClawConfig & { + channels: { + telegram: NonNullable["telegram"]; + }; + }, + account: createTelegramAccount(cfg.channels!.telegram), + accountId: "default", + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.telegram.allowFrom.invalid_entries", + severity: "warn", + }), + ]), + ); + }); + }); +}); diff --git a/src/security/audit.channel-security.test.ts b/src/security/audit.channel-security.test.ts deleted file mode 100644 index f411c7ae0a2..00000000000 --- a/src/security/audit.channel-security.test.ts +++ /dev/null @@ -1,723 +0,0 @@ -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 type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; -import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { runSecurityAudit } from "./audit.js"; - -let channelSecurityContractsPromise: - | Promise - | undefined; - -async function loadChannelSecurityContracts() { - channelSecurityContractsPromise ??= - import("../../test/helpers/channels/security-audit-contract.js"); - return await channelSecurityContractsPromise; -} - -function createLazyChannelCollectAuditFindings( - id: "discord" | "feishu" | "slack" | "synology-chat" | "telegram" | "zalouser", -): NonNullable["collectAuditFindings"] { - return async (...args) => { - const contracts = await loadChannelSecurityContracts(); - const handler = - id === "discord" - ? contracts.collectDiscordSecurityAuditFindings - : id === "feishu" - ? contracts.collectFeishuSecurityAuditFindings - : id === "slack" - ? contracts.collectSlackSecurityAuditFindings - : id === "synology-chat" - ? contracts.collectSynologyChatSecurityAuditFindings - : id === "telegram" - ? contracts.collectTelegramSecurityAuditFindings - : contracts.collectZalouserSecurityAuditFindings; - return await handler(...args); - }; -} - -function stubChannelPlugin(params: { - id: "discord" | "feishu" | "slack" | "synology-chat" | "telegram" | "zalouser"; - label: string; - resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; - inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown; - listAccountIds?: (cfg: OpenClawConfig) => string[]; - isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean; - isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean; - collectAuditFindings?: NonNullable["collectAuditFindings"]; - commands?: ChannelPlugin["commands"]; -}): ChannelPlugin { - const channelConfigured = (cfg: OpenClawConfig) => - Boolean((cfg.channels as Record | undefined)?.[params.id]); - const defaultCollectAuditFindings = - params.collectAuditFindings ?? createLazyChannelCollectAuditFindings(params.id); - const defaultCommands = - params.commands ?? - (params.id === "discord" || params.id === "telegram" - ? { - nativeCommandsAutoEnabled: true, - nativeSkillsAutoEnabled: true, - } - : params.id === "slack" - ? { - nativeCommandsAutoEnabled: false, - nativeSkillsAutoEnabled: false, - } - : undefined); - return { - id: params.id, - meta: { - id: params.id, - label: params.label, - selectionLabel: params.label, - docsPath: "/docs/testing", - blurb: "test stub", - }, - capabilities: { - chatTypes: ["direct", "group"], - }, - ...(defaultCommands ? { commands: defaultCommands } : {}), - security: defaultCollectAuditFindings - ? { - collectAuditFindings: defaultCollectAuditFindings, - } - : {}, - config: { - listAccountIds: - params.listAccountIds ?? - ((cfg) => { - const enabled = Boolean( - (cfg.channels as Record | undefined)?.[params.id], - ); - return enabled ? ["default"] : []; - }), - inspectAccount: - params.inspectAccount ?? - ((cfg, accountId) => { - const resolvedAccountId = - typeof accountId === "string" && accountId ? accountId : "default"; - let account: { config?: Record } | undefined; - try { - account = params.resolveAccount(cfg, resolvedAccountId) as - | { config?: Record } - | undefined; - } catch { - return null; - } - const config = account?.config ?? {}; - return { - accountId: resolvedAccountId, - enabled: params.isEnabled?.(account, cfg) ?? channelConfigured(cfg), - configured: params.isConfigured?.(account, cfg) ?? channelConfigured(cfg), - config, - }; - }), - resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId), - isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? channelConfigured(cfg), - isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? channelConfigured(cfg), - }, - }; -} - -const discordPlugin = stubChannelPlugin({ - id: "discord", - label: "Discord", - listAccountIds: (cfg) => { - const ids = Object.keys(cfg.channels?.discord?.accounts ?? {}); - return ids.length > 0 ? ids : ["default"]; - }, - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; - const base = cfg.channels?.discord ?? {}; - const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {}; - return { config: { ...base, ...account } }; - }, -}); - -const slackPlugin = stubChannelPlugin({ - id: "slack", - label: "Slack", - listAccountIds: (cfg) => { - const ids = Object.keys(cfg.channels?.slack?.accounts ?? {}); - return ids.length > 0 ? ids : ["default"]; - }, - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; - const base = cfg.channels?.slack ?? {}; - const account = cfg.channels?.slack?.accounts?.[resolvedAccountId] ?? {}; - return { config: { ...base, ...account } }; - }, -}); - -const telegramPlugin = stubChannelPlugin({ - id: "telegram", - label: "Telegram", - listAccountIds: (cfg) => { - const ids = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - return ids.length > 0 ? ids : ["default"]; - }, - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; - const base = cfg.channels?.telegram ?? {}; - const account = cfg.channels?.telegram?.accounts?.[resolvedAccountId] ?? {}; - return { config: { ...base, ...account } }; - }, -}); - -const zalouserPlugin = stubChannelPlugin({ - id: "zalouser", - label: "Zalo Personal", - listAccountIds: (cfg) => { - const channel = (cfg.channels as Record | undefined)?.zalouser as - | { accounts?: Record } - | undefined; - const ids = Object.keys(channel?.accounts ?? {}); - return ids.length > 0 ? ids : ["default"]; - }, - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; - const channel = (cfg.channels as Record | undefined)?.zalouser as - | { accounts?: Record } - | undefined; - const base = (channel ?? {}) as Record; - const account = channel?.accounts?.[resolvedAccountId] ?? {}; - return { config: { ...base, ...account } }; - }, -}); - -const synologyChatPlugin = stubChannelPlugin({ - id: "synology-chat", - label: "Synology Chat", - listAccountIds: (cfg) => { - const ids = Object.keys(cfg.channels?.["synology-chat"]?.accounts ?? {}); - return ids.length > 0 ? ids : ["default"]; - }, - inspectAccount: () => null, - resolveAccount: (cfg, accountId) => { - const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default"; - const base = cfg.channels?.["synology-chat"] ?? {}; - const account = cfg.channels?.["synology-chat"]?.accounts?.[resolvedAccountId] ?? {}; - const dangerouslyAllowNameMatching = - typeof account.dangerouslyAllowNameMatching === "boolean" - ? account.dangerouslyAllowNameMatching - : base.dangerouslyAllowNameMatching === true; - return { - accountId: resolvedAccountId, - enabled: true, - dangerouslyAllowNameMatching, - }; - }, -}); - -async function withActiveAuditChannelPlugins( - plugins: ChannelPlugin[], - run: () => Promise, -): Promise { - const previousRegistry = getActivePluginRegistry(); - const registry = createEmptyPluginRegistry(); - registry.channels = plugins.map((plugin) => ({ - pluginId: plugin.id, - plugin, - source: "test", - })); - setActivePluginRegistry(registry); - try { - return await run(); - } finally { - setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry()); - } -} - -async function runChannelSecurityAudit( - cfg: OpenClawConfig, - plugins: ChannelPlugin[], -): Promise>> { - return withActiveAuditChannelPlugins(plugins, () => - runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins, - }), - ); -} - -describe("security audit channel security", () => { - let fixtureRoot = ""; - let sharedChannelSecurityStateDir = ""; - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-channel-")); - sharedChannelSecurityStateDir = path.join(fixtureRoot, "state"); - await fs.mkdir(path.join(sharedChannelSecurityStateDir, "credentials"), { - recursive: true, - mode: 0o700, - }); - }); - - afterAll(async () => { - if (!fixtureRoot) { - return; - } - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - }); - - const withChannelSecurityStateDir = async (fn: (tmp: string) => Promise) => { - const credentialsDir = path.join(sharedChannelSecurityStateDir, "credentials"); - await fs.rm(credentialsDir, { recursive: true, force: true }).catch(() => undefined); - await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 }); - await withEnvAsync({ OPENCLAW_STATE_DIR: sharedChannelSecurityStateDir }, () => - fn(sharedChannelSecurityStateDir), - ); - }; - - it.each([ - { - name: "warns when Discord allowlists contain name-based entries", - setup: async (tmp: string) => { - await fs.writeFile( - path.join(tmp, "credentials", "discord-allowFrom.json"), - JSON.stringify({ version: 1, allowFrom: ["team.owner"] }), - ); - }, - cfg: { - channels: { - discord: { - enabled: true, - token: "t", - allowFrom: ["Alice#1234", "<@123456789012345678>"], - guilds: { - "123": { - users: ["trusted.operator"], - channels: { - general: { - users: ["987654321098765432", "security-team"], - }, - }, - }, - }, - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectNameBasedSeverity: "warn", - detailIncludes: [ - "channels.discord.allowFrom:Alice#1234", - "channels.discord.guilds.123.users:trusted.operator", - "channels.discord.guilds.123.channels.general.users:security-team", - "~/.openclaw/credentials/discord-allowFrom.json:team.owner", - ], - detailExcludes: ["<@123456789012345678>"], - }, - { - name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled", - cfg: { - channels: { - discord: { - enabled: true, - token: "t", - dangerouslyAllowNameMatching: true, - allowFrom: ["Alice#1234"], - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectNameBasedSeverity: "info", - detailIncludes: ["out-of-scope"], - expectFindingMatch: { - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }, - }, - { - name: "audits non-default Discord accounts for dangerous name matching", - cfg: { - channels: { - discord: { - enabled: true, - token: "t", - accounts: { - alpha: { token: "a" }, - beta: { - token: "b", - dangerouslyAllowNameMatching: true, - }, - }, - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectNoNameBasedFinding: true, - expectFindingMatch: { - checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled", - title: expect.stringContaining("(account: beta)"), - severity: "info", - }, - }, - { - name: "audits name-based allowlists on non-default Discord accounts", - cfg: { - channels: { - discord: { - enabled: true, - token: "t", - accounts: { - alpha: { - token: "a", - allowFrom: ["123456789012345678"], - }, - beta: { - token: "b", - allowFrom: ["Alice#1234"], - }, - }, - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectNameBasedSeverity: "warn", - detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"], - }, - { - name: "does not warn when Discord allowlists use ID-style entries only", - cfg: { - channels: { - discord: { - enabled: true, - token: "t", - allowFrom: [ - "123456789012345678", - "<@223456789012345678>", - "user:323456789012345678", - "discord:423456789012345678", - "pk:member-123", - ], - guilds: { - "123": { - users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"], - channels: { - general: { - users: ["723456789012345678", "user:823456789012345678"], - }, - }, - }, - }, - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectNoNameBasedFinding: true, - }, - ])("$name", async (testCase) => { - await withChannelSecurityStateDir(async (tmp) => { - await testCase.setup?.(tmp); - const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); - const nameBasedFinding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - - if (testCase.expectNoNameBasedFinding) { - expect(nameBasedFinding).toBeUndefined(); - } else if ( - testCase.expectNameBasedSeverity || - testCase.detailIncludes?.length || - testCase.detailExcludes?.length - ) { - expect(nameBasedFinding).toBeDefined(); - if (testCase.expectNameBasedSeverity) { - expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity); - } - for (const snippet of testCase.detailIncludes ?? []) { - expect(nameBasedFinding?.detail).toContain(snippet); - } - for (const snippet of testCase.detailExcludes ?? []) { - expect(nameBasedFinding?.detail).not.toContain(snippet); - } - } - - if (testCase.expectFindingMatch) { - expect(res.findings).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), - ); - } - }); - }); - - it.each([ - { - name: "audits Synology Chat base dangerous name matching", - cfg: { - channels: { - "synology-chat": { - token: "t", - incomingUrl: "https://nas.example.com/incoming", - dangerouslyAllowNameMatching: true, - }, - }, - } satisfies OpenClawConfig, - expectedMatch: { - checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", - severity: "info", - title: "Synology Chat dangerous name matching is enabled", - }, - }, - { - name: "audits non-default Synology Chat accounts for dangerous name matching", - cfg: { - channels: { - "synology-chat": { - token: "t", - incomingUrl: "https://nas.example.com/incoming", - accounts: { - alpha: { - token: "a", - incomingUrl: "https://nas.example.com/incoming-alpha", - }, - beta: { - token: "b", - incomingUrl: "https://nas.example.com/incoming-beta", - dangerouslyAllowNameMatching: true, - }, - }, - }, - }, - } satisfies OpenClawConfig, - expectedMatch: { - checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", - severity: "info", - title: expect.stringContaining("(account: beta)"), - }, - }, - ])("$name", async (testCase) => { - await withChannelSecurityStateDir(async () => { - const res = await runChannelSecurityAudit(testCase.cfg, [synologyChatPlugin]); - expect(res.findings).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]), - ); - }); - }); - - it("does not treat prototype properties as explicit Discord account config paths", async () => { - await withChannelSecurityStateDir(async () => { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "t", - dangerouslyAllowNameMatching: true, - allowFrom: ["Alice#1234"], - accounts: {}, - }, - }, - }; - - const pluginWithProtoDefaultAccount: ChannelPlugin = { - ...discordPlugin, - config: { - ...discordPlugin.config, - listAccountIds: () => [], - defaultAccountId: () => "toString", - }, - }; - - const res = await withActiveAuditChannelPlugins([pluginWithProtoDefaultAccount], () => - runSecurityAudit({ - config: cfg, - includeFilesystem: false, - includeChannelSecurity: true, - plugins: [pluginWithProtoDefaultAccount], - }), - ); - - const dangerousMatchingFinding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled", - ); - expect(dangerousMatchingFinding).toBeDefined(); - expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)"); - - const nameBasedFinding = res.findings.find( - (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", - ); - expect(nameBasedFinding).toBeDefined(); - expect(nameBasedFinding?.detail).toContain("channels.discord.allowFrom:Alice#1234"); - expect(nameBasedFinding?.detail).not.toContain("channels.discord.accounts.toString"); - }); - }); - - it.each([ - { - name: "warns when Zalouser group routing contains mutable group entries", - cfg: { - channels: { - zalouser: { - enabled: true, - groups: { - "Ops Room": { allow: true }, - "group:g-123": { allow: true }, - }, - }, - }, - } satisfies OpenClawConfig, - expectedSeverity: "warn", - detailIncludes: ["channels.zalouser.groups:Ops Room"], - detailExcludes: ["group:g-123"], - }, - { - name: "marks Zalouser mutable group routing as break-glass when dangerous matching is enabled", - cfg: { - channels: { - zalouser: { - enabled: true, - dangerouslyAllowNameMatching: true, - groups: { - "Ops Room": { allow: true }, - }, - }, - }, - } satisfies OpenClawConfig, - expectedSeverity: "info", - detailIncludes: ["out-of-scope"], - expectFindingMatch: { - checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled", - severity: "info", - }, - }, - ])("$name", async (testCase) => { - await withChannelSecurityStateDir(async () => { - const res = await runChannelSecurityAudit(testCase.cfg, [zalouserPlugin]); - const finding = res.findings.find( - (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries", - ); - - expect(finding).toBeDefined(); - expect(finding?.severity).toBe(testCase.expectedSeverity); - for (const snippet of testCase.detailIncludes) { - expect(finding?.detail).toContain(snippet); - } - for (const snippet of testCase.detailExcludes ?? []) { - expect(finding?.detail).not.toContain(snippet); - } - if (testCase.expectFindingMatch) { - expect(res.findings).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]), - ); - } - }); - }); - - it.each([ - { - name: "flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", - cfg: { - commands: { useAccessGroups: false }, - channels: { - discord: { - enabled: true, - token: "t", - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { enabled: true }, - }, - }, - }, - }, - }, - } satisfies OpenClawConfig, - plugins: [discordPlugin], - expectedFinding: { - checkId: "channels.discord.commands.native.unrestricted", - severity: "critical", - }, - }, - { - name: "flags Slack slash commands without a channel users allowlist", - cfg: { - channels: { - slack: { - enabled: true, - botToken: "xoxb-test", - appToken: "xapp-test", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - } satisfies OpenClawConfig, - plugins: [slackPlugin], - expectedFinding: { - checkId: "channels.slack.commands.slash.no_allowlists", - severity: "warn", - }, - }, - { - name: "flags Slack slash commands when access-group enforcement is disabled", - cfg: { - commands: { useAccessGroups: false }, - channels: { - slack: { - enabled: true, - botToken: "xoxb-test", - appToken: "xapp-test", - groupPolicy: "open", - slashCommand: { enabled: true }, - }, - }, - } satisfies OpenClawConfig, - plugins: [slackPlugin], - expectedFinding: { - checkId: "channels.slack.commands.slash.useAccessGroups_off", - severity: "critical", - }, - }, - { - name: "flags Telegram group commands without a sender allowlist", - cfg: { - channels: { - telegram: { - enabled: true, - botToken: "t", - groupPolicy: "allowlist", - groups: { "-100123": {} }, - }, - }, - } satisfies OpenClawConfig, - plugins: [telegramPlugin], - expectedFinding: { - checkId: "channels.telegram.groups.allowFrom.missing", - severity: "critical", - }, - }, - { - name: "warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", - cfg: { - channels: { - telegram: { - enabled: true, - botToken: "t", - groupPolicy: "allowlist", - groupAllowFrom: ["@TrustedOperator"], - groups: { "-100123": {} }, - }, - }, - } satisfies OpenClawConfig, - plugins: [telegramPlugin], - expectedFinding: { - checkId: "channels.telegram.allowFrom.invalid_entries", - severity: "warn", - }, - }, - ])("$name", async (testCase) => { - await withChannelSecurityStateDir(async () => { - const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins); - - expect(res.findings).toEqual( - expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]), - ); - }); - }); -});