diff --git a/extensions/discord/src/security-audit.test.ts b/extensions/discord/src/security-audit.test.ts new file mode 100644 index 00000000000..d0152ae0061 --- /dev/null +++ b/extensions/discord/src/security-audit.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedDiscordAccount } from "./accounts.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { collectDiscordSecurityAuditFindings } from "./security-audit.js"; + +type DiscordAccountConfig = ResolvedDiscordAccount["config"]; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +function createAccount( + config: DiscordAccountConfig, + accountId = "default", +): ResolvedDiscordAccount { + return { + accountId, + enabled: true, + token: "t", + tokenSource: "config", + config, + }; +} + +async function collectFindings(params: { + cfg: OpenClawConfig; + config: DiscordAccountConfig; + accountId?: string; + orderedAccountIds?: string[]; + hasExplicitAccountPath?: boolean; + storeAllowFrom?: string[]; +}) { + readChannelAllowFromStoreMock.mockResolvedValue(params.storeAllowFrom ?? []); + return await collectDiscordSecurityAuditFindings({ + cfg: params.cfg, + account: createAccount(params.config, params.accountId), + accountId: params.accountId ?? "default", + orderedAccountIds: params.orderedAccountIds ?? ["default"], + hasExplicitAccountPath: params.hasExplicitAccountPath ?? false, + }); +} + +describe("Discord security audit findings", () => { + it("flags 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 }, + }, + }, + }, + }, + }, + }; + + const discordConfig = cfg.channels?.discord; + if (!discordConfig) { + throw new Error("discord config required"); + } + const findings = await collectFindings({ + cfg, + config: discordConfig, + }); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "channels.discord.commands.native.unrestricted", + severity: "critical", + }), + ]), + ); + }); + + it.each([ + { + name: "flags missing guild user allowlists", + cfg: { + commands: { native: true }, + channels: { + discord: { + enabled: true, + token: "t", + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { enabled: true }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectFinding: true, + }, + { + name: "does not flag when dm.allowFrom includes a Discord snowflake id", + cfg: { + commands: { native: true }, + channels: { + discord: { + enabled: true, + token: "t", + dm: { allowFrom: ["387380367612706819"] }, + groupPolicy: "allowlist", + guilds: { + "123": { + channels: { + general: { enabled: true }, + }, + }, + }, + }, + }, + } satisfies OpenClawConfig, + expectFinding: false, + }, + ])("$name", async (testCase) => { + const findings = await collectFindings({ + cfg: testCase.cfg, + config: testCase.cfg.channels.discord, + }); + + expect( + findings.some( + (finding) => finding.checkId === "channels.discord.commands.native.no_allowlists", + ), + ).toBe(testCase.expectFinding); + }); + + it.each([ + { + name: "warns when Discord allowlists contain name-based entries", + config: { + enabled: true, + token: "t", + allowFrom: ["Alice#1234", "<@123456789012345678>"], + guilds: { + "123": { + users: ["trusted.operator"], + channels: { + general: { + users: ["987654321098765432", "security-team"], + }, + }, + }, + }, + } satisfies DiscordAccountConfig, + storeAllowFrom: ["team.owner"], + 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", + config: { + enabled: true, + token: "t", + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice#1234"], + } satisfies DiscordAccountConfig, + expectNameBasedSeverity: "info", + detailIncludes: ["out-of-scope"], + }, + { + name: "audits name-based allowlists on non-default Discord accounts", + accountId: "beta", + orderedAccountIds: ["alpha", "beta"], + hasExplicitAccountPath: true, + config: { + enabled: true, + token: "b", + allowFrom: ["Alice#1234"], + } satisfies DiscordAccountConfig, + expectNameBasedSeverity: "warn", + detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"], + }, + { + name: "does not warn when Discord allowlists use ID-style entries only", + config: { + 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 DiscordAccountConfig, + expectNoNameBasedFinding: true, + }, + ])("$name", async (testCase) => { + const findings = await collectFindings({ + cfg: { channels: { discord: testCase.config } }, + config: testCase.config, + accountId: testCase.accountId, + orderedAccountIds: testCase.orderedAccountIds, + hasExplicitAccountPath: testCase.hasExplicitAccountPath, + storeAllowFrom: testCase.storeAllowFrom, + }); + const nameBasedFinding = findings.find( + (entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries", + ); + + if (testCase.expectNoNameBasedFinding) { + expect(nameBasedFinding).toBeUndefined(); + } else { + expect(nameBasedFinding).toBeDefined(); + 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); + } + } + }); +}); diff --git a/src/security/audit-feishu-doc-risk.test.ts b/extensions/feishu/src/security-audit.test.ts similarity index 74% rename from src/security/audit-feishu-doc-risk.test.ts rename to extensions/feishu/src/security-audit.test.ts index f0e8cbb0d5c..edeb0e29aeb 100644 --- a/src/security/audit-feishu-doc-risk.test.ts +++ b/extensions/feishu/src/security-audit.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; -import { collectFeishuSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.js"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; -describe("security audit Feishu doc risk findings", () => { +describe("Feishu security audit findings", () => { it.each([ { - name: "warns when Feishu doc tool is enabled because create can grant requester access", + name: "warns when doc tool is enabled because create can grant requester access", cfg: { channels: { feishu: { @@ -17,7 +17,7 @@ describe("security audit Feishu doc risk findings", () => { expectedFinding: "channels.feishu.doc_owner_open_id", }, { - name: "treats Feishu SecretRef appSecret as configured for doc tool risk detection", + name: "treats SecretRef appSecret as configured for doc tool risk detection", cfg: { channels: { feishu: { @@ -33,7 +33,7 @@ describe("security audit Feishu doc risk findings", () => { expectedFinding: "channels.feishu.doc_owner_open_id", }, { - name: "does not warn for Feishu doc grant risk when doc tools are disabled", + name: "does not warn for doc grant risk when doc tools are disabled", cfg: { channels: { feishu: { diff --git a/extensions/slack/src/security-audit.test.ts b/extensions/slack/src/security-audit.test.ts new file mode 100644 index 00000000000..3bb3b37844c --- /dev/null +++ b/extensions/slack/src/security-audit.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResolvedSlackAccount } from "./accounts.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { collectSlackSecurityAuditFindings } from "./security-audit.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"]) { + return { + accountId: "default", + enabled: true, + botToken: "xoxb-test", + botTokenSource: "config", + appTokenSource: "config", + config, + } as ResolvedSlackAccount; +} + +describe("Slack security audit findings", () => { + it("flags 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 }, + }, + }, + }; + + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectSlackSecurityAuditFindings({ + cfg, + 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 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 }, + }, + }, + }; + + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectSlackSecurityAuditFindings({ + cfg, + 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/extensions/synology-chat/src/security-audit.test.ts b/extensions/synology-chat/src/security-audit.test.ts new file mode 100644 index 00000000000..e883d3e1f0d --- /dev/null +++ b/extensions/synology-chat/src/security-audit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js"; +import type { ResolvedSynologyChatAccount } from "./types.js"; + +function createAccount(params: { + accountId: string; + dangerouslyAllowNameMatching?: boolean; +}): ResolvedSynologyChatAccount { + return { + accountId: params.accountId, + enabled: true, + token: "t", + incomingUrl: "https://nas.example.com/incoming", + nasHost: "https://nas.example.com", + webhookPath: "/webapi/entry.cgi", + webhookPathSource: "explicit", + dangerouslyAllowNameMatching: params.dangerouslyAllowNameMatching ?? false, + dangerouslyAllowInheritedWebhookPath: false, + dmPolicy: "allowlist", + allowedUserIds: [], + rateLimitPerMinute: 30, + botName: "OpenClaw", + allowInsecureSsl: false, + }; +} + +describe("Synology Chat security audit findings", () => { + it.each([ + { + name: "audits base dangerous name matching", + accountId: "default", + orderedAccountIds: [] as string[], + hasExplicitAccountPath: false, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: "Synology Chat dangerous name matching is enabled", + }, + }, + { + name: "audits non-default accounts for dangerous name matching", + accountId: "beta", + orderedAccountIds: ["alpha", "beta"], + hasExplicitAccountPath: true, + expectedMatch: { + checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled", + severity: "info", + title: expect.stringContaining("(account: beta)"), + }, + }, + ])("$name", (testCase) => { + const findings = collectSynologyChatSecurityAuditFindings({ + account: createAccount({ + accountId: testCase.accountId, + dangerouslyAllowNameMatching: true, + }), + accountId: testCase.accountId, + orderedAccountIds: testCase.orderedAccountIds, + hasExplicitAccountPath: testCase.hasExplicitAccountPath, + }); + + expect(findings).toEqual( + expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]), + ); + }); +}); diff --git a/extensions/telegram/src/security-audit.test.ts b/extensions/telegram/src/security-audit.test.ts new file mode 100644 index 00000000000..033b5facd1b --- /dev/null +++ b/extensions/telegram/src/security-audit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; +import type { ResolvedTelegramAccount } from "./accounts.js"; +import { collectTelegramSecurityAuditFindings } from "./security-audit.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, + token: "t", + tokenSource: "config", + config, + }; +} + +describe("Telegram security audit findings", () => { + it("flags group commands without a sender allowlist", async () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + botToken: "t", + groupPolicy: "allowlist", + groups: { "-100123": {} }, + }, + }, + }; + + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectTelegramSecurityAuditFindings({ + cfg, + 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 allowFrom entries are non-numeric legacy @username configs", async () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + enabled: true, + botToken: "t", + groupPolicy: "allowlist", + groupAllowFrom: ["@TrustedOperator"], + groups: { "-100123": {} }, + }, + }, + }; + + readChannelAllowFromStoreMock.mockResolvedValue([]); + const findings = await collectTelegramSecurityAuditFindings({ + cfg, + 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/extensions/zalouser/src/security-audit.test.ts b/extensions/zalouser/src/security-audit.test.ts new file mode 100644 index 00000000000..e2549a34f7a --- /dev/null +++ b/extensions/zalouser/src/security-audit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { collectZalouserSecurityAuditFindings } from "./security-audit.js"; +import type { ResolvedZalouserAccount, ZalouserAccountConfig } from "./types.js"; + +function createAccount(config: ZalouserAccountConfig): ResolvedZalouserAccount { + return { + accountId: "default", + enabled: true, + profile: "default", + authenticated: true, + config, + }; +} + +describe("Zalouser security audit findings", () => { + const cases: Array<{ + name: string; + config: ZalouserAccountConfig; + expectedSeverity: "info" | "warn"; + detailIncludes: string[]; + detailExcludes?: string[]; + expectFindingMatch?: { checkId: string; severity: "info" | "warn" }; + }> = [ + { + name: "warns when group routing contains mutable group entries", + config: { + enabled: true, + groups: { + "Ops Room": { enabled: true }, + "group:g-123": { enabled: true }, + }, + } satisfies ZalouserAccountConfig, + expectedSeverity: "warn", + detailIncludes: ["channels.zalouser.groups:Ops Room"], + detailExcludes: ["group:g-123"], + }, + { + name: "marks mutable group routing as break-glass when dangerous matching is enabled", + config: { + enabled: true, + dangerouslyAllowNameMatching: true, + groups: { + "Ops Room": { enabled: true }, + }, + } satisfies ZalouserAccountConfig, + expectedSeverity: "info", + detailIncludes: ["out-of-scope"], + expectFindingMatch: { + checkId: "channels.zalouser.groups.mutable_entries", + severity: "info", + }, + }, + ]; + + it.each(cases)("$name", (testCase) => { + const findings = collectZalouserSecurityAuditFindings({ + account: createAccount(testCase.config), + accountId: "default", + orderedAccountIds: ["default"], + hasExplicitAccountPath: false, + }); + 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/channels/plugins/contracts/group-policy.provider-owned.contract.test.ts b/src/channels/plugins/contracts/group-policy.provider-owned.contract.test.ts deleted file mode 100644 index 4f0a6351327..00000000000 --- a/src/channels/plugins/contracts/group-policy.provider-owned.contract.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { evaluateZaloGroupAccess } from "../../../plugin-sdk/zalo-setup.js"; - -function expectAllowedZaloGroupAccess(params: Parameters[0]) { - expect(evaluateZaloGroupAccess(params)).toMatchObject({ - allowed: true, - groupPolicy: "allowlist", - reason: "allowed", - }); -} - -function expectAllowedZaloGroupAccessCase( - params: Omit[0], "groupAllowFrom"> & { - groupAllowFrom: readonly string[]; - }, -) { - expectAllowedZaloGroupAccess({ - ...params, - groupAllowFrom: [...params.groupAllowFrom], - }); -} - -describe("channel runtime group policy provider-owned contract", () => { - describe("zalo", () => { - it.each([ - { - providerConfigPresent: true, - configuredGroupPolicy: "allowlist", - defaultGroupPolicy: "open", - groupAllowFrom: ["zl:12345"], - senderId: "12345", - }, - ] as const)("keeps provider-owned group access evaluation %#", (testCase) => { - expectAllowedZaloGroupAccessCase(testCase); - }); - }); -}); diff --git a/src/security/audit-channel-discord-allowlists.test.ts b/src/security/audit-channel-discord-allowlists.test.ts deleted file mode 100644 index 0fa6637996c..00000000000 --- a/src/security/audit-channel-discord-allowlists.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { collectDiscordSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.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 deleted file mode 100644 index 0744a39c50d..00000000000 --- a/src/security/audit-channel-discord-command-findings.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { collectDiscordSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; - -type DiscordAuditParams = Parameters[0]; -type ResolvedDiscordAccount = DiscordAuditParams["account"]; -type DiscordAccountConfig = ResolvedDiscordAccount["config"]; - -const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ - readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), -})); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: readChannelAllowFromStoreMock, -})); - -function createDiscordAccount(config: DiscordAccountConfig): 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: DiscordAccountConfig; - }; - }, - 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-discord-native.test.ts b/src/security/audit-channel-discord-native.test.ts deleted file mode 100644 index 84c77bd0443..00000000000 --- a/src/security/audit-channel-discord-native.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { collectDiscordSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.js"; -import type { OpenClawConfig } from "../config/config.js"; - -type DiscordAuditParams = Parameters[0]; -type ResolvedDiscordAccount = DiscordAuditParams["account"]; -type DiscordAccountConfig = ResolvedDiscordAccount["config"]; - -const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ - readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]), -})); - -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ - readChannelAllowFromStore: readChannelAllowFromStoreMock, -})); - -function createAccount(config: DiscordAccountConfig): ResolvedDiscordAccount { - return { - accountId: "default", - enabled: true, - token: "t", - tokenSource: "config", - config, - }; -} - -describe("security audit discord native command findings", () => { - it("evaluates Discord native command allowlist findings", async () => { - const cases = [ - { - name: "flags missing guild user allowlists", - cfg: { - commands: { native: true }, - channels: { - discord: { - enabled: true, - token: "t", - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { enabled: true }, - }, - }, - }, - }, - }, - } as OpenClawConfig, - expectFinding: true, - }, - { - name: "does not flag when dm.allowFrom includes a Discord snowflake id", - cfg: { - commands: { native: true }, - channels: { - discord: { - enabled: true, - token: "t", - dm: { allowFrom: ["387380367612706819"] }, - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { enabled: true }, - }, - }, - }, - }, - }, - } as OpenClawConfig, - expectFinding: false, - }, - ] as const; - - for (const testCase of cases) { - readChannelAllowFromStoreMock.mockResolvedValue([]); - const discord = testCase.cfg.channels?.discord; - if (!discord) { - throw new Error("discord config required"); - } - const findings = await collectDiscordSecurityAuditFindings({ - cfg: testCase.cfg as OpenClawConfig & { channels: { discord: DiscordAccountConfig } }, - account: createAccount(discord), - accountId: "default", - orderedAccountIds: ["default"], - hasExplicitAccountPath: false, - }); - - expect( - findings.some( - (finding) => finding.checkId === "channels.discord.commands.native.no_allowlists", - ), - testCase.name, - ).toBe(testCase.expectFinding); - } - }); -}); diff --git a/src/security/audit-channel-slack-command-findings.test.ts b/src/security/audit-channel-slack-command-findings.test.ts deleted file mode 100644 index 8557477f0e6..00000000000 --- a/src/security/audit-channel-slack-command-findings.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { collectSlackSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.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"], -): Parameters[0]["account"] { - return { - accountId: "default", - enabled: true, - botToken: "xoxb-test", - botTokenSource: "config", - appTokenSource: "config", - config, - } as Parameters[0]["account"]; -} - -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 deleted file mode 100644 index b04c4147bea..00000000000 --- a/src/security/audit-channel-synology-zalo.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - collectSynologyChatSecurityAuditFindings, - collectZalouserSecurityAuditFindings, -} from "../../test/helpers/channels/security-audit-contract.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"; - -type SynologyAuditParams = Parameters[0]; -type ResolvedSynologyChatAccount = SynologyAuditParams["account"]; -type ZalouserAuditParams = Parameters[0]; -type ResolvedZalouserAccount = ZalouserAuditParams["account"]; - -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, - }, - }; -} - -function createSynologyChatAccount(params: { - cfg: OpenClawConfig; - accountId: string; -}): ResolvedSynologyChatAccount { - const channel = params.cfg.channels?.["synology-chat"] ?? {}; - const accountConfig = - params.accountId === "default" ? channel : (channel.accounts?.[params.accountId] ?? {}); - return { - accountId: params.accountId, - dangerouslyAllowNameMatching: - Boolean( - (accountConfig as { dangerouslyAllowNameMatching?: boolean }).dangerouslyAllowNameMatching, - ) || - Boolean( - params.accountId === "default" && - (channel as { dangerouslyAllowNameMatching?: boolean }).dangerouslyAllowNameMatching, - ), - } as ResolvedSynologyChatAccount; -} - -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: createSynologyChatAccount({ cfg: testCase.cfg, accountId }), - 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 deleted file mode 100644 index 08eff05ace1..00000000000 --- a/src/security/audit-channel-telegram-command-findings.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { collectTelegramSecurityAuditFindings } from "../../test/helpers/channels/security-audit-contract.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js"; - -type TelegramAuditParams = Parameters[0]; -type ResolvedTelegramAccount = TelegramAuditParams["account"]; - -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", - }), - ]), - ); - }); - }); -});