mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-23 07:01:40 +00:00
perf(test): split security audit coverage
This commit is contained in:
@@ -34,24 +34,24 @@ export async function collectSlackSecurityAuditFindings(params: {
|
||||
}> = [];
|
||||
const slackCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
const slashCommandEnabled =
|
||||
nativeEnabled ||
|
||||
nativeSkillsEnabled ||
|
||||
const slashCommandConfigured =
|
||||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
|
||||
const slashCommandEnabled =
|
||||
slashCommandConfigured ||
|
||||
resolveNativeCommandsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
}) ||
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(slackCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
if (!slashCommandEnabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
96
src/security/audit-channel-discord-native.test.ts
Normal file
96
src/security/audit-channel-discord-native.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js";
|
||||
import type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({
|
||||
readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
}));
|
||||
|
||||
function createAccount(
|
||||
config: NonNullable<OpenClawConfig["channels"]>["discord"],
|
||||
): 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: typeof discord } },
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
56
src/security/audit-channel-dm-policy.test.ts
Normal file
56
src/security/audit-channel-dm-policy.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
|
||||
describe("security audit channel dm policy", () => {
|
||||
it("warns when multiple DM senders share the main session", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: { dmScope: "main" },
|
||||
channels: { whatsapp: { enabled: true } },
|
||||
};
|
||||
const plugins: ChannelPlugin[] = [
|
||||
{
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "Test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({}),
|
||||
isEnabled: () => true,
|
||||
isConfigured: () => true,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: () => ({
|
||||
policy: "allowlist",
|
||||
allowFrom: ["user-a", "user-b"],
|
||||
policyPath: "channels.whatsapp.dmPolicy",
|
||||
allowFromPath: "channels.whatsapp.",
|
||||
approveHint: "approve",
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const findings = await collectChannelSecurityFindings({
|
||||
cfg,
|
||||
plugins,
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.whatsapp.dm.scope_main_multiuser",
|
||||
severity: "warn",
|
||||
remediation: expect.stringContaining('config set session.dmScope "per-channel-peer"'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
64
src/security/audit-channel-readonly-resolution.test.ts
Normal file
64
src/security/audit-channel-readonly-resolution.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
|
||||
function stubChannelPlugin(params: {
|
||||
id: "zalouser";
|
||||
label: string;
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: "/docs/testing",
|
||||
blurb: "test stub",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
},
|
||||
security: {},
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: () => true,
|
||||
isConfigured: () => true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("security audit channel read-only resolution", () => {
|
||||
it("adds a read-only resolution warning when channel account resolveAccount throws", async () => {
|
||||
const plugin = stubChannelPlugin({
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
resolveAccount: () => {
|
||||
throw new Error("missing SecretRef");
|
||||
},
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const findings = await collectChannelSecurityFindings({
|
||||
cfg,
|
||||
plugins: [plugin],
|
||||
});
|
||||
|
||||
const finding = findings.find(
|
||||
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
|
||||
);
|
||||
expect(finding?.severity).toBe("warn");
|
||||
expect(finding?.title).toContain("could not be fully resolved");
|
||||
expect(finding?.detail).toContain("zalouser:default: failed to resolve account");
|
||||
expect(finding?.detail).toContain("missing SecretRef");
|
||||
});
|
||||
});
|
||||
149
src/security/audit-channel-source-config-discord.test.ts
Normal file
149
src/security/audit-channel-source-config-discord.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { 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(params: {
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
}): 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: () => ["default"],
|
||||
inspectAccount:
|
||||
params.inspectAccount ??
|
||||
((cfg, accountId) => {
|
||||
const resolvedAccountId =
|
||||
typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const account = params.resolveAccount(cfg, resolvedAccountId) as
|
||||
| { config?: Record<string, unknown> }
|
||||
| undefined;
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: account?.config ?? {},
|
||||
};
|
||||
}),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: () => true,
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("security audit channel source-config fallback discord", () => {
|
||||
it("keeps source-configured channel security findings when resolved inspection is incomplete", async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
commands: { native: true },
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
general: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
commands: { native: true },
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
general: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
readChannelAllowFromStoreMock.mockResolvedValue([]);
|
||||
const findings = await collectChannelSecurityFindings({
|
||||
cfg: resolvedConfig,
|
||||
sourceConfig,
|
||||
plugins: [
|
||||
stubDiscordPlugin({
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.discord ?? {};
|
||||
const token = channel.token;
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token,
|
||||
token: "",
|
||||
tokenSource:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token
|
||||
? "config"
|
||||
: "none",
|
||||
tokenStatus:
|
||||
Boolean(token) &&
|
||||
typeof token === "object" &&
|
||||
!Array.isArray(token) &&
|
||||
"source" in token
|
||||
? "configured_unavailable"
|
||||
: "missing",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.discord ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.discord.commands.native.no_allowlists",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
204
src/security/audit-channel-source-config-slack.test.ts
Normal file
204
src/security/audit-channel-source-config-slack.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
|
||||
function stubSlackPlugin(params: {
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/docs/testing",
|
||||
blurb: "test stub",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
},
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: false,
|
||||
nativeSkillsAutoEnabled: false,
|
||||
},
|
||||
security: {
|
||||
collectAuditFindings: async ({ account }) => {
|
||||
const config =
|
||||
(account as { config?: { slashCommand?: { enabled?: boolean }; allowFrom?: unknown } })
|
||||
.config ?? {};
|
||||
const slashCommandEnabled = config.slashCommand?.enabled === true;
|
||||
const allowFrom =
|
||||
Array.isArray(config.allowFrom) && config.allowFrom.length > 0 ? config.allowFrom : [];
|
||||
if (!slashCommandEnabled || allowFrom.length > 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn" as const,
|
||||
title: "Slack slash commands have no allowlists",
|
||||
detail: "test stub",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
inspectAccount:
|
||||
params.inspectAccount ??
|
||||
((cfg, accountId) => {
|
||||
const resolvedAccountId =
|
||||
typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const account = params.resolveAccount(cfg, resolvedAccountId) as
|
||||
| { config?: Record<string, unknown> }
|
||||
| undefined;
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: account?.config ?? {},
|
||||
};
|
||||
}),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: () => true,
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("security audit channel source-config fallback slack", () => {
|
||||
it("keeps source-configured channel security findings when resolved inspection is incomplete", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "slack resolved inspection only exposes signingSecret status",
|
||||
sourceConfig: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
resolvedConfig: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
plugin: (sourceConfig: OpenClawConfig) =>
|
||||
stubSlackPlugin({
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.slack ?? {};
|
||||
if (cfg === sourceConfig) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: false,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
config: channel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "available",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "slack source config still wins when resolved inspection is unconfigured",
|
||||
sourceConfig: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
resolvedConfig: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
plugin: (sourceConfig: OpenClawConfig) =>
|
||||
stubSlackPlugin({
|
||||
inspectAccount: (cfg) => {
|
||||
const channel = cfg.channels?.slack ?? {};
|
||||
if (cfg === sourceConfig) {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
config: channel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
mode: "http",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "missing",
|
||||
config: channel,
|
||||
};
|
||||
},
|
||||
resolveAccount: (cfg) => ({ config: cfg.channels?.slack ?? {} }),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
}),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const findings = await collectChannelSecurityFindings({
|
||||
cfg: testCase.resolvedConfig,
|
||||
sourceConfig: testCase.sourceConfig,
|
||||
plugins: [testCase.plugin(testCase.sourceConfig)],
|
||||
});
|
||||
|
||||
expect(findings, testCase.name).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
114
src/security/audit-gateway.test.ts
Normal file
114
src/security/audit-gateway.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { collectGatewayConfigFindings } from "./audit.js";
|
||||
|
||||
function hasFinding(checkId: string, findings: ReturnType<typeof collectGatewayConfigFindings>) {
|
||||
return findings.some((finding) => finding.checkId === checkId);
|
||||
}
|
||||
|
||||
function hasFindingWithSeverity(
|
||||
checkId: string,
|
||||
severity: "info" | "warn" | "critical",
|
||||
findings: ReturnType<typeof collectGatewayConfigFindings>,
|
||||
) {
|
||||
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
|
||||
}
|
||||
|
||||
describe("security audit gateway config findings", () => {
|
||||
it("evaluates gateway auth presence and rate-limit guardrails", async () => {
|
||||
await Promise.all([
|
||||
withEnvAsync(
|
||||
{
|
||||
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||
OPENCLAW_GATEWAY_PASSWORD: undefined,
|
||||
},
|
||||
async () => {
|
||||
const findings = collectGatewayConfigFindings(
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
process.env,
|
||||
);
|
||||
expect(hasFindingWithSeverity("gateway.bind_no_auth", "critical", findings)).toBe(true);
|
||||
},
|
||||
),
|
||||
(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_GATEWAY_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const findings = collectGatewayConfigFindings(cfg, cfg, {});
|
||||
expect(hasFinding("gateway.bind_no_auth", findings)).toBe(false);
|
||||
})(),
|
||||
(async () => {
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENCLAW_GATEWAY_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolvedConfig: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
secrets: sourceConfig.secrets,
|
||||
};
|
||||
const findings = collectGatewayConfigFindings(resolvedConfig, sourceConfig, {});
|
||||
expect(hasFinding("gateway.bind_no_auth", findings)).toBe(false);
|
||||
})(),
|
||||
(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: { token: "secret" },
|
||||
},
|
||||
};
|
||||
const findings = collectGatewayConfigFindings(cfg, cfg, {});
|
||||
expect(hasFindingWithSeverity("gateway.auth_no_rate_limit", "warn", findings)).toBe(true);
|
||||
})(),
|
||||
(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {
|
||||
token: "secret",
|
||||
rateLimit: { maxAttempts: 10, windowMs: 60_000, lockoutMs: 300_000 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const findings = collectGatewayConfigFindings(cfg, cfg, {});
|
||||
expect(hasFinding("gateway.auth_no_rate_limit", findings)).toBe(false);
|
||||
})(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
24
src/security/audit-summary.test.ts
Normal file
24
src/security/audit-summary.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectAttackSurfaceSummaryFindings } from "./audit-extra.sync.js";
|
||||
|
||||
describe("security audit attack surface summary", () => {
|
||||
it("includes an attack surface summary (info)", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { groupPolicy: "open" }, telegram: { groupPolicy: "allowlist" } },
|
||||
tools: { elevated: { enabled: true, allowFrom: { whatsapp: ["+1"] } } },
|
||||
hooks: { enabled: true },
|
||||
browser: { enabled: true },
|
||||
};
|
||||
|
||||
const findings = collectAttackSurfaceSummaryFindings(cfg);
|
||||
const summary = findings.find((f) => f.checkId === "summary.attack_surface");
|
||||
|
||||
expect(findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ checkId: "summary.attack_surface", severity: "info" }),
|
||||
]),
|
||||
);
|
||||
expect(summary?.detail).toContain("trust model: personal assistant");
|
||||
});
|
||||
});
|
||||
723
src/security/audit.channel-security.test.ts
Normal file
723
src/security/audit.channel-security.test.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { runSecurityAudit } from "./audit.js";
|
||||
|
||||
let channelSecurityContractsPromise:
|
||||
| Promise<typeof import("../../test/helpers/channels/security-audit-contract.js")>
|
||||
| undefined;
|
||||
|
||||
async function loadChannelSecurityContracts() {
|
||||
channelSecurityContractsPromise ??=
|
||||
import("../../test/helpers/channels/security-audit-contract.js");
|
||||
return await channelSecurityContractsPromise;
|
||||
}
|
||||
|
||||
function createLazyChannelCollectAuditFindings(
|
||||
id: "discord" | "feishu" | "slack" | "synology-chat" | "telegram" | "zalouser",
|
||||
): NonNullable<ChannelPlugin["security"]>["collectAuditFindings"] {
|
||||
return async (...args) => {
|
||||
const contracts = await loadChannelSecurityContracts();
|
||||
const handler =
|
||||
id === "discord"
|
||||
? contracts.collectDiscordSecurityAuditFindings
|
||||
: id === "feishu"
|
||||
? contracts.collectFeishuSecurityAuditFindings
|
||||
: id === "slack"
|
||||
? contracts.collectSlackSecurityAuditFindings
|
||||
: id === "synology-chat"
|
||||
? contracts.collectSynologyChatSecurityAuditFindings
|
||||
: id === "telegram"
|
||||
? contracts.collectTelegramSecurityAuditFindings
|
||||
: contracts.collectZalouserSecurityAuditFindings;
|
||||
return await handler(...args);
|
||||
};
|
||||
}
|
||||
|
||||
function stubChannelPlugin(params: {
|
||||
id: "discord" | "feishu" | "slack" | "synology-chat" | "telegram" | "zalouser";
|
||||
label: string;
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId: string | null | undefined) => unknown;
|
||||
listAccountIds?: (cfg: OpenClawConfig) => string[];
|
||||
isConfigured?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
isEnabled?: (account: unknown, cfg: OpenClawConfig) => boolean;
|
||||
collectAuditFindings?: NonNullable<ChannelPlugin["security"]>["collectAuditFindings"];
|
||||
commands?: ChannelPlugin["commands"];
|
||||
}): ChannelPlugin {
|
||||
const channelConfigured = (cfg: OpenClawConfig) =>
|
||||
Boolean((cfg.channels as Record<string, unknown> | undefined)?.[params.id]);
|
||||
const defaultCollectAuditFindings =
|
||||
params.collectAuditFindings ?? createLazyChannelCollectAuditFindings(params.id);
|
||||
const defaultCommands =
|
||||
params.commands ??
|
||||
(params.id === "discord" || params.id === "telegram"
|
||||
? {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: true,
|
||||
}
|
||||
: params.id === "slack"
|
||||
? {
|
||||
nativeCommandsAutoEnabled: false,
|
||||
nativeSkillsAutoEnabled: false,
|
||||
}
|
||||
: undefined);
|
||||
return {
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
selectionLabel: params.label,
|
||||
docsPath: "/docs/testing",
|
||||
blurb: "test stub",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
},
|
||||
...(defaultCommands ? { commands: defaultCommands } : {}),
|
||||
security: defaultCollectAuditFindings
|
||||
? {
|
||||
collectAuditFindings: defaultCollectAuditFindings,
|
||||
}
|
||||
: {},
|
||||
config: {
|
||||
listAccountIds:
|
||||
params.listAccountIds ??
|
||||
((cfg) => {
|
||||
const enabled = Boolean(
|
||||
(cfg.channels as Record<string, unknown> | undefined)?.[params.id],
|
||||
);
|
||||
return enabled ? ["default"] : [];
|
||||
}),
|
||||
inspectAccount:
|
||||
params.inspectAccount ??
|
||||
((cfg, accountId) => {
|
||||
const resolvedAccountId =
|
||||
typeof accountId === "string" && accountId ? accountId : "default";
|
||||
let account: { config?: Record<string, unknown> } | undefined;
|
||||
try {
|
||||
account = params.resolveAccount(cfg, resolvedAccountId) as
|
||||
| { config?: Record<string, unknown> }
|
||||
| undefined;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const config = account?.config ?? {};
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
|
||||
configured: params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg, accountId),
|
||||
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
|
||||
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const discordPlugin = stubChannelPlugin({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
listAccountIds: (cfg) => {
|
||||
const ids = Object.keys(cfg.channels?.discord?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const base = cfg.channels?.discord ?? {};
|
||||
const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {};
|
||||
return { config: { ...base, ...account } };
|
||||
},
|
||||
});
|
||||
|
||||
const slackPlugin = stubChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
listAccountIds: (cfg) => {
|
||||
const ids = Object.keys(cfg.channels?.slack?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const base = cfg.channels?.slack ?? {};
|
||||
const account = cfg.channels?.slack?.accounts?.[resolvedAccountId] ?? {};
|
||||
return { config: { ...base, ...account } };
|
||||
},
|
||||
});
|
||||
|
||||
const telegramPlugin = stubChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
listAccountIds: (cfg) => {
|
||||
const ids = Object.keys(cfg.channels?.telegram?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const base = cfg.channels?.telegram ?? {};
|
||||
const account = cfg.channels?.telegram?.accounts?.[resolvedAccountId] ?? {};
|
||||
return { config: { ...base, ...account } };
|
||||
},
|
||||
});
|
||||
|
||||
const zalouserPlugin = stubChannelPlugin({
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
listAccountIds: (cfg) => {
|
||||
const channel = (cfg.channels as Record<string, unknown> | undefined)?.zalouser as
|
||||
| { accounts?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const ids = Object.keys(channel?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const channel = (cfg.channels as Record<string, unknown> | undefined)?.zalouser as
|
||||
| { accounts?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const base = (channel ?? {}) as Record<string, unknown>;
|
||||
const account = channel?.accounts?.[resolvedAccountId] ?? {};
|
||||
return { config: { ...base, ...account } };
|
||||
},
|
||||
});
|
||||
|
||||
const synologyChatPlugin = stubChannelPlugin({
|
||||
id: "synology-chat",
|
||||
label: "Synology Chat",
|
||||
listAccountIds: (cfg) => {
|
||||
const ids = Object.keys(cfg.channels?.["synology-chat"]?.accounts ?? {});
|
||||
return ids.length > 0 ? ids : ["default"];
|
||||
},
|
||||
inspectAccount: () => null,
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId = typeof accountId === "string" && accountId ? accountId : "default";
|
||||
const base = cfg.channels?.["synology-chat"] ?? {};
|
||||
const account = cfg.channels?.["synology-chat"]?.accounts?.[resolvedAccountId] ?? {};
|
||||
const dangerouslyAllowNameMatching =
|
||||
typeof account.dangerouslyAllowNameMatching === "boolean"
|
||||
? account.dangerouslyAllowNameMatching
|
||||
: base.dangerouslyAllowNameMatching === true;
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
enabled: true,
|
||||
dangerouslyAllowNameMatching,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
async function withActiveAuditChannelPlugins<T>(
|
||||
plugins: ChannelPlugin[],
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousRegistry = getActivePluginRegistry();
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.channels = plugins.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
plugin,
|
||||
source: "test",
|
||||
}));
|
||||
setActivePluginRegistry(registry);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
setActivePluginRegistry(previousRegistry ?? createEmptyPluginRegistry());
|
||||
}
|
||||
}
|
||||
|
||||
async function runChannelSecurityAudit(
|
||||
cfg: OpenClawConfig,
|
||||
plugins: ChannelPlugin[],
|
||||
): Promise<Awaited<ReturnType<typeof runSecurityAudit>>> {
|
||||
return withActiveAuditChannelPlugins(plugins, () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("security audit channel security", () => {
|
||||
let fixtureRoot = "";
|
||||
let sharedChannelSecurityStateDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-channel-"));
|
||||
sharedChannelSecurityStateDir = path.join(fixtureRoot, "state");
|
||||
await fs.mkdir(path.join(sharedChannelSecurityStateDir, "credentials"), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!fixtureRoot) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
|
||||
const withChannelSecurityStateDir = async (fn: (tmp: string) => Promise<void>) => {
|
||||
const credentialsDir = path.join(sharedChannelSecurityStateDir, "credentials");
|
||||
await fs.rm(credentialsDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.mkdir(credentialsDir, { recursive: true, mode: 0o700 });
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: sharedChannelSecurityStateDir }, () =>
|
||||
fn(sharedChannelSecurityStateDir),
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "warns when Discord allowlists contain name-based entries",
|
||||
setup: async (tmp: string) => {
|
||||
await fs.writeFile(
|
||||
path.join(tmp, "credentials", "discord-allowFrom.json"),
|
||||
JSON.stringify({ version: 1, allowFrom: ["team.owner"] }),
|
||||
);
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
allowFrom: ["Alice#1234", "<@123456789012345678>"],
|
||||
guilds: {
|
||||
"123": {
|
||||
users: ["trusted.operator"],
|
||||
channels: {
|
||||
general: {
|
||||
users: ["987654321098765432", "security-team"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectNameBasedSeverity: "warn",
|
||||
detailIncludes: [
|
||||
"channels.discord.allowFrom:Alice#1234",
|
||||
"channels.discord.guilds.123.users:trusted.operator",
|
||||
"channels.discord.guilds.123.channels.general.users:security-team",
|
||||
"~/.openclaw/credentials/discord-allowFrom.json:team.owner",
|
||||
],
|
||||
detailExcludes: ["<@123456789012345678>"],
|
||||
},
|
||||
{
|
||||
name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
allowFrom: ["Alice#1234"],
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectNameBasedSeverity: "info",
|
||||
detailIncludes: ["out-of-scope"],
|
||||
expectFindingMatch: {
|
||||
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
|
||||
severity: "info",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audits non-default Discord accounts for dangerous name matching",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
accounts: {
|
||||
alpha: { token: "a" },
|
||||
beta: {
|
||||
token: "b",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectNoNameBasedFinding: true,
|
||||
expectFindingMatch: {
|
||||
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
|
||||
title: expect.stringContaining("(account: beta)"),
|
||||
severity: "info",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audits name-based allowlists on non-default Discord accounts",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
accounts: {
|
||||
alpha: {
|
||||
token: "a",
|
||||
allowFrom: ["123456789012345678"],
|
||||
},
|
||||
beta: {
|
||||
token: "b",
|
||||
allowFrom: ["Alice#1234"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectNameBasedSeverity: "warn",
|
||||
detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"],
|
||||
},
|
||||
{
|
||||
name: "does not warn when Discord allowlists use ID-style entries only",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
allowFrom: [
|
||||
"123456789012345678",
|
||||
"<@223456789012345678>",
|
||||
"user:323456789012345678",
|
||||
"discord:423456789012345678",
|
||||
"pk:member-123",
|
||||
],
|
||||
guilds: {
|
||||
"123": {
|
||||
users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"],
|
||||
channels: {
|
||||
general: {
|
||||
users: ["723456789012345678", "user:823456789012345678"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectNoNameBasedFinding: true,
|
||||
},
|
||||
])("$name", async (testCase) => {
|
||||
await withChannelSecurityStateDir(async (tmp) => {
|
||||
await testCase.setup?.(tmp);
|
||||
const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins);
|
||||
const nameBasedFinding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
|
||||
);
|
||||
|
||||
if (testCase.expectNoNameBasedFinding) {
|
||||
expect(nameBasedFinding).toBeUndefined();
|
||||
} else if (
|
||||
testCase.expectNameBasedSeverity ||
|
||||
testCase.detailIncludes?.length ||
|
||||
testCase.detailExcludes?.length
|
||||
) {
|
||||
expect(nameBasedFinding).toBeDefined();
|
||||
if (testCase.expectNameBasedSeverity) {
|
||||
expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity);
|
||||
}
|
||||
for (const snippet of testCase.detailIncludes ?? []) {
|
||||
expect(nameBasedFinding?.detail).toContain(snippet);
|
||||
}
|
||||
for (const snippet of testCase.detailExcludes ?? []) {
|
||||
expect(nameBasedFinding?.detail).not.toContain(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
if (testCase.expectFindingMatch) {
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "audits Synology Chat base dangerous name matching",
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": {
|
||||
token: "t",
|
||||
incomingUrl: "https://nas.example.com/incoming",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expectedMatch: {
|
||||
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
|
||||
severity: "info",
|
||||
title: "Synology Chat dangerous name matching is enabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audits non-default Synology Chat accounts for dangerous name matching",
|
||||
cfg: {
|
||||
channels: {
|
||||
"synology-chat": {
|
||||
token: "t",
|
||||
incomingUrl: "https://nas.example.com/incoming",
|
||||
accounts: {
|
||||
alpha: {
|
||||
token: "a",
|
||||
incomingUrl: "https://nas.example.com/incoming-alpha",
|
||||
},
|
||||
beta: {
|
||||
token: "b",
|
||||
incomingUrl: "https://nas.example.com/incoming-beta",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expectedMatch: {
|
||||
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
|
||||
severity: "info",
|
||||
title: expect.stringContaining("(account: beta)"),
|
||||
},
|
||||
},
|
||||
])("$name", async (testCase) => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const res = await runChannelSecurityAudit(testCase.cfg, [synologyChatPlugin]);
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining(testCase.expectedMatch)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat prototype properties as explicit Discord account config paths", async () => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
allowFrom: ["Alice#1234"],
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pluginWithProtoDefaultAccount: ChannelPlugin = {
|
||||
...discordPlugin,
|
||||
config: {
|
||||
...discordPlugin.config,
|
||||
listAccountIds: () => [],
|
||||
defaultAccountId: () => "toString",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await withActiveAuditChannelPlugins([pluginWithProtoDefaultAccount], () =>
|
||||
runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: true,
|
||||
plugins: [pluginWithProtoDefaultAccount],
|
||||
}),
|
||||
);
|
||||
|
||||
const dangerousMatchingFinding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled",
|
||||
);
|
||||
expect(dangerousMatchingFinding).toBeDefined();
|
||||
expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)");
|
||||
|
||||
const nameBasedFinding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
|
||||
);
|
||||
expect(nameBasedFinding).toBeDefined();
|
||||
expect(nameBasedFinding?.detail).toContain("channels.discord.allowFrom:Alice#1234");
|
||||
expect(nameBasedFinding?.detail).not.toContain("channels.discord.accounts.toString");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "warns when Zalouser group routing contains mutable group entries",
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
groups: {
|
||||
"Ops Room": { allow: true },
|
||||
"group:g-123": { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expectedSeverity: "warn",
|
||||
detailIncludes: ["channels.zalouser.groups:Ops Room"],
|
||||
detailExcludes: ["group:g-123"],
|
||||
},
|
||||
{
|
||||
name: "marks Zalouser mutable group routing as break-glass when dangerous matching is enabled",
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dangerouslyAllowNameMatching: true,
|
||||
groups: {
|
||||
"Ops Room": { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
expectedSeverity: "info",
|
||||
detailIncludes: ["out-of-scope"],
|
||||
expectFindingMatch: {
|
||||
checkId: "channels.zalouser.allowFrom.dangerous_name_matching_enabled",
|
||||
severity: "info",
|
||||
},
|
||||
},
|
||||
])("$name", async (testCase) => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const res = await runChannelSecurityAudit(testCase.cfg, [zalouserPlugin]);
|
||||
const finding = res.findings.find(
|
||||
(entry) => entry.checkId === "channels.zalouser.groups.mutable_entries",
|
||||
);
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe(testCase.expectedSeverity);
|
||||
for (const snippet of testCase.detailIncludes) {
|
||||
expect(finding?.detail).toContain(snippet);
|
||||
}
|
||||
for (const snippet of testCase.detailExcludes ?? []) {
|
||||
expect(finding?.detail).not.toContain(snippet);
|
||||
}
|
||||
if (testCase.expectFindingMatch) {
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists",
|
||||
cfg: {
|
||||
commands: { useAccessGroups: false },
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
groupPolicy: "allowlist",
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
general: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [discordPlugin],
|
||||
expectedFinding: {
|
||||
checkId: "channels.discord.commands.native.unrestricted",
|
||||
severity: "critical",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flags Slack slash commands without a channel users allowlist",
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [slackPlugin],
|
||||
expectedFinding: {
|
||||
checkId: "channels.slack.commands.slash.no_allowlists",
|
||||
severity: "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flags Slack slash commands when access-group enforcement is disabled",
|
||||
cfg: {
|
||||
commands: { useAccessGroups: false },
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
groupPolicy: "open",
|
||||
slashCommand: { enabled: true },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [slackPlugin],
|
||||
expectedFinding: {
|
||||
checkId: "channels.slack.commands.slash.useAccessGroups_off",
|
||||
severity: "critical",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flags Telegram group commands without a sender allowlist",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
botToken: "t",
|
||||
groupPolicy: "allowlist",
|
||||
groups: { "-100123": {} },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [telegramPlugin],
|
||||
expectedFinding: {
|
||||
checkId: "channels.telegram.groups.allowFrom.missing",
|
||||
severity: "critical",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "warns when Telegram allowFrom entries are non-numeric (legacy @username configs)",
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
botToken: "t",
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["@TrustedOperator"],
|
||||
groups: { "-100123": {} },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig,
|
||||
plugins: [telegramPlugin],
|
||||
expectedFinding: {
|
||||
checkId: "channels.telegram.allowFrom.invalid_entries",
|
||||
severity: "warn",
|
||||
},
|
||||
},
|
||||
])("$name", async (testCase) => {
|
||||
await withChannelSecurityStateDir(async () => {
|
||||
const res = await runChannelSecurityAudit(testCase.cfg, testCase.plugins);
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js";
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
|
||||
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
|
||||
import type { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
@@ -341,7 +341,7 @@ async function collectFilesystemFindings(params: {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function collectGatewayConfigFindings(
|
||||
export function collectGatewayConfigFindings(
|
||||
cfg: OpenClawConfig,
|
||||
sourceConfig: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
|
||||
Reference in New Issue
Block a user