mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
test(extensions): move channel security coverage
This commit is contained in:
246
extensions/discord/src/security-audit.test.ts
Normal file
246
extensions/discord/src/security-audit.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
86
extensions/slack/src/security-audit.test.ts
Normal file
86
extensions/slack/src/security-audit.test.ts
Normal file
@@ -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<OpenClawConfig["channels"]>["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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
66
extensions/synology-chat/src/security-audit.test.ts
Normal file
66
extensions/synology-chat/src/security-audit.test.ts
Normal file
@@ -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)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
85
extensions/telegram/src/security-audit.test.ts
Normal file
85
extensions/telegram/src/security-audit.test.ts
Normal file
@@ -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<NonNullable<OpenClawConfig["channels"]>["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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
80
extensions/zalouser/src/security-audit.test.ts
Normal file
80
extensions/zalouser/src/security-audit.test.ts
Normal file
@@ -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)]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateZaloGroupAccess } from "../../../plugin-sdk/zalo-setup.js";
|
||||
|
||||
function expectAllowedZaloGroupAccess(params: Parameters<typeof evaluateZaloGroupAccess>[0]) {
|
||||
expect(evaluateZaloGroupAccess(params)).toMatchObject({
|
||||
allowed: true,
|
||||
groupPolicy: "allowlist",
|
||||
reason: "allowed",
|
||||
});
|
||||
}
|
||||
|
||||
function expectAllowedZaloGroupAccessCase(
|
||||
params: Omit<Parameters<typeof evaluateZaloGroupAccess>[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof collectDiscordSecurityAuditFindings>[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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof collectDiscordSecurityAuditFindings>[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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<OpenClawConfig["channels"]>["slack"],
|
||||
): Parameters<typeof collectSlackSecurityAuditFindings>[0]["account"] {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "xoxb-test",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
config,
|
||||
} as Parameters<typeof collectSlackSecurityAuditFindings>[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<OpenClawConfig["channels"]>["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<OpenClawConfig["channels"]>["slack"];
|
||||
};
|
||||
},
|
||||
account: createSlackAccount(cfg.channels!.slack),
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.slack.commands.slash.useAccessGroups_off",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof collectSynologyChatSecurityAuditFindings>[0];
|
||||
type ResolvedSynologyChatAccount = SynologyAuditParams["account"];
|
||||
type ZalouserAuditParams = Parameters<typeof collectZalouserSecurityAuditFindings>[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)]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof collectTelegramSecurityAuditFindings>[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<OpenClawConfig["channels"]>["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<OpenClawConfig["channels"]>["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<OpenClawConfig["channels"]>["telegram"];
|
||||
};
|
||||
},
|
||||
account: createTelegramAccount(cfg.channels!.telegram),
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.telegram.allowFrom.invalid_entries",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user