Skip to content

Commit 2810a4f

Browse files
committed
perf(test): split audit channel security coverage
1 parent ec20e33 commit 2810a4f

7 files changed

Lines changed: 768 additions & 723 deletions
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js";
5+
import type { ChannelPlugin } from "../channels/plugins/types.js";
6+
import type { OpenClawConfig } from "../config/config.js";
7+
import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js";
8+
import { collectChannelSecurityFindings } from "./audit-channel.js";
9+
10+
const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({
11+
readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]),
12+
}));
13+
14+
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
15+
readChannelAllowFromStore: readChannelAllowFromStoreMock,
16+
}));
17+
18+
function stubDiscordPlugin(): ChannelPlugin {
19+
return {
20+
id: "discord",
21+
meta: {
22+
id: "discord",
23+
label: "Discord",
24+
selectionLabel: "Discord",
25+
docsPath: "/docs/testing",
26+
blurb: "test stub",
27+
},
28+
capabilities: {
29+
chatTypes: ["direct", "group"],
30+
},
31+
commands: {
32+
nativeCommandsAutoEnabled: true,
33+
nativeSkillsAutoEnabled: true,
34+
},
35+
security: {
36+
collectAuditFindings: collectDiscordSecurityAuditFindings,
37+
},
38+
config: {
39+
listAccountIds: (cfg) => {
40+
const ids = Object.keys(cfg.channels?.discord?.accounts ?? {});
41+
return ids.length > 0 ? ids : ["default"];
42+
},
43+
inspectAccount: (cfg, accountId) => {
44+
const resolvedAccountId =
45+
typeof accountId === "string" && accountId ? accountId : "default";
46+
const base = cfg.channels?.discord ?? {};
47+
const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {};
48+
return {
49+
accountId: resolvedAccountId,
50+
enabled: true,
51+
configured: true,
52+
token: "t",
53+
tokenSource: "config",
54+
config: { ...base, ...account },
55+
};
56+
},
57+
resolveAccount: (cfg, accountId) => {
58+
const resolvedAccountId =
59+
typeof accountId === "string" && accountId ? accountId : "default";
60+
const base = cfg.channels?.discord ?? {};
61+
const account = cfg.channels?.discord?.accounts?.[resolvedAccountId] ?? {};
62+
return {
63+
accountId: resolvedAccountId,
64+
enabled: true,
65+
token: "t",
66+
tokenSource: "config",
67+
config: { ...base, ...account },
68+
};
69+
},
70+
isEnabled: () => true,
71+
isConfigured: () => true,
72+
},
73+
};
74+
}
75+
76+
describe("security audit discord allowlists", () => {
77+
it.each([
78+
{
79+
name: "warns when Discord allowlists contain name-based entries",
80+
setup: async (tmp: string) => {
81+
await fs.writeFile(
82+
path.join(tmp, "credentials", "discord-allowFrom.json"),
83+
JSON.stringify({ version: 1, allowFrom: ["team.owner"] }),
84+
);
85+
},
86+
cfg: {
87+
channels: {
88+
discord: {
89+
enabled: true,
90+
token: "t",
91+
allowFrom: ["Alice#1234", "<@123456789012345678>"],
92+
guilds: {
93+
"123": {
94+
users: ["trusted.operator"],
95+
channels: {
96+
general: {
97+
users: ["987654321098765432", "security-team"],
98+
},
99+
},
100+
},
101+
},
102+
},
103+
},
104+
} satisfies OpenClawConfig,
105+
expectNameBasedSeverity: "warn",
106+
detailIncludes: [
107+
"channels.discord.allowFrom:Alice#1234",
108+
"channels.discord.guilds.123.users:trusted.operator",
109+
"channels.discord.guilds.123.channels.general.users:security-team",
110+
"~/.openclaw/credentials/discord-allowFrom.json:team.owner",
111+
],
112+
detailExcludes: ["<@123456789012345678>"],
113+
},
114+
{
115+
name: "marks Discord name-based allowlists as break-glass when dangerous matching is enabled",
116+
cfg: {
117+
channels: {
118+
discord: {
119+
enabled: true,
120+
token: "t",
121+
dangerouslyAllowNameMatching: true,
122+
allowFrom: ["Alice#1234"],
123+
},
124+
},
125+
} satisfies OpenClawConfig,
126+
expectNameBasedSeverity: "info",
127+
detailIncludes: ["out-of-scope"],
128+
expectFindingMatch: {
129+
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
130+
severity: "info",
131+
},
132+
},
133+
{
134+
name: "audits non-default Discord accounts for dangerous name matching",
135+
cfg: {
136+
channels: {
137+
discord: {
138+
enabled: true,
139+
token: "t",
140+
accounts: {
141+
alpha: { token: "a" },
142+
beta: {
143+
token: "b",
144+
dangerouslyAllowNameMatching: true,
145+
},
146+
},
147+
},
148+
},
149+
} satisfies OpenClawConfig,
150+
expectNoNameBasedFinding: true,
151+
expectFindingMatch: {
152+
checkId: "channels.discord.allowFrom.dangerous_name_matching_enabled",
153+
title: expect.stringContaining("(account: beta)"),
154+
severity: "info",
155+
},
156+
},
157+
{
158+
name: "audits name-based allowlists on non-default Discord accounts",
159+
cfg: {
160+
channels: {
161+
discord: {
162+
enabled: true,
163+
token: "t",
164+
accounts: {
165+
alpha: {
166+
token: "a",
167+
allowFrom: ["123456789012345678"],
168+
},
169+
beta: {
170+
token: "b",
171+
allowFrom: ["Alice#1234"],
172+
},
173+
},
174+
},
175+
},
176+
} satisfies OpenClawConfig,
177+
expectNameBasedSeverity: "warn",
178+
detailIncludes: ["channels.discord.accounts.beta.allowFrom:Alice#1234"],
179+
},
180+
{
181+
name: "does not warn when Discord allowlists use ID-style entries only",
182+
cfg: {
183+
channels: {
184+
discord: {
185+
enabled: true,
186+
token: "t",
187+
allowFrom: [
188+
"123456789012345678",
189+
"<@223456789012345678>",
190+
"user:323456789012345678",
191+
"discord:423456789012345678",
192+
"pk:member-123",
193+
],
194+
guilds: {
195+
"123": {
196+
users: ["523456789012345678", "<@623456789012345678>", "pk:member-456"],
197+
channels: {
198+
general: {
199+
users: ["723456789012345678", "user:823456789012345678"],
200+
},
201+
},
202+
},
203+
},
204+
},
205+
},
206+
} satisfies OpenClawConfig,
207+
expectNoNameBasedFinding: true,
208+
},
209+
])("$name", async (testCase) => {
210+
await withChannelSecurityStateDir(async (tmp) => {
211+
await testCase.setup?.(tmp);
212+
readChannelAllowFromStoreMock.mockResolvedValue(
213+
testCase.detailIncludes?.includes(
214+
"~/.openclaw/credentials/discord-allowFrom.json:team.owner",
215+
)
216+
? ["team.owner"]
217+
: [],
218+
);
219+
const findings = await collectChannelSecurityFindings({
220+
cfg: testCase.cfg,
221+
plugins: [stubDiscordPlugin()],
222+
});
223+
const nameBasedFinding = findings.find(
224+
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
225+
);
226+
227+
if (testCase.expectNoNameBasedFinding) {
228+
expect(nameBasedFinding).toBeUndefined();
229+
} else if (
230+
testCase.expectNameBasedSeverity ||
231+
testCase.detailIncludes?.length ||
232+
testCase.detailExcludes?.length
233+
) {
234+
expect(nameBasedFinding).toBeDefined();
235+
if (testCase.expectNameBasedSeverity) {
236+
expect(nameBasedFinding?.severity).toBe(testCase.expectNameBasedSeverity);
237+
}
238+
for (const snippet of testCase.detailIncludes ?? []) {
239+
expect(nameBasedFinding?.detail).toContain(snippet);
240+
}
241+
for (const snippet of testCase.detailExcludes ?? []) {
242+
expect(nameBasedFinding?.detail).not.toContain(snippet);
243+
}
244+
}
245+
246+
if (testCase.expectFindingMatch) {
247+
const matchingFinding = findings.find(
248+
(entry) => entry.checkId === testCase.expectFindingMatch.checkId,
249+
);
250+
expect(matchingFinding).toEqual(expect.objectContaining(testCase.expectFindingMatch));
251+
}
252+
});
253+
});
254+
255+
it("does not treat prototype properties as explicit Discord account config paths", async () => {
256+
await withChannelSecurityStateDir(async () => {
257+
const cfg: OpenClawConfig = {
258+
channels: {
259+
discord: {
260+
enabled: true,
261+
token: "t",
262+
dangerouslyAllowNameMatching: true,
263+
allowFrom: ["Alice#1234"],
264+
accounts: {},
265+
},
266+
},
267+
};
268+
269+
readChannelAllowFromStoreMock.mockResolvedValue([]);
270+
const pluginWithProtoDefaultAccount: ChannelPlugin = {
271+
...stubDiscordPlugin(),
272+
config: {
273+
...stubDiscordPlugin().config,
274+
listAccountIds: () => [],
275+
defaultAccountId: () => "toString",
276+
},
277+
};
278+
const findings = await collectChannelSecurityFindings({
279+
cfg,
280+
plugins: [pluginWithProtoDefaultAccount],
281+
});
282+
283+
const dangerousMatchingFinding = findings.find(
284+
(entry) => entry.checkId === "channels.discord.allowFrom.dangerous_name_matching_enabled",
285+
);
286+
expect(dangerousMatchingFinding).toBeDefined();
287+
expect(dangerousMatchingFinding?.title).not.toContain("(account: toString)");
288+
289+
const nameBasedFinding = findings.find(
290+
(entry) => entry.checkId === "channels.discord.allowFrom.name_based_entries",
291+
);
292+
expect(nameBasedFinding).toBeDefined();
293+
expect(nameBasedFinding?.detail).toContain("channels.discord.allowFrom:Alice#1234");
294+
expect(nameBasedFinding?.detail).not.toContain("channels.discord.accounts.toString");
295+
});
296+
});
297+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/contract-api.js";
3+
import type { ResolvedDiscordAccount } from "../../extensions/discord/src/accounts.js";
4+
import type { OpenClawConfig } from "../config/config.js";
5+
import { withChannelSecurityStateDir } from "./audit-channel-security.test-helpers.js";
6+
7+
const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({
8+
readChannelAllowFromStoreMock: vi.fn(async () => [] as string[]),
9+
}));
10+
11+
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
12+
readChannelAllowFromStore: readChannelAllowFromStoreMock,
13+
}));
14+
15+
function createDiscordAccount(
16+
config: NonNullable<OpenClawConfig["channels"]>["discord"],
17+
): ResolvedDiscordAccount {
18+
return {
19+
accountId: "default",
20+
enabled: true,
21+
token: "t",
22+
tokenSource: "config",
23+
config,
24+
};
25+
}
26+
27+
describe("security audit discord command findings", () => {
28+
it("flags Discord slash commands when access-group enforcement is disabled and no users allowlist exists", async () => {
29+
const cfg: OpenClawConfig = {
30+
commands: { native: true, useAccessGroups: false },
31+
channels: {
32+
discord: {
33+
enabled: true,
34+
token: "t",
35+
groupPolicy: "allowlist",
36+
guilds: {
37+
"123": {
38+
channels: {
39+
general: { enabled: true },
40+
},
41+
},
42+
},
43+
},
44+
},
45+
};
46+
47+
await withChannelSecurityStateDir(async () => {
48+
readChannelAllowFromStoreMock.mockResolvedValue([]);
49+
const findings = await collectDiscordSecurityAuditFindings({
50+
cfg: cfg as OpenClawConfig & {
51+
channels: {
52+
discord: NonNullable<OpenClawConfig["channels"]>["discord"];
53+
};
54+
},
55+
account: createDiscordAccount(cfg.channels!.discord),
56+
accountId: "default",
57+
orderedAccountIds: ["default"],
58+
hasExplicitAccountPath: false,
59+
});
60+
61+
expect(findings).toEqual(
62+
expect.arrayContaining([
63+
expect.objectContaining({
64+
checkId: "channels.discord.commands.native.unrestricted",
65+
severity: "critical",
66+
}),
67+
]),
68+
);
69+
});
70+
});
71+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { withEnvAsync } from "../test-utils/env.js";
5+
6+
export async function withChannelSecurityStateDir(fn: (tmp: string) => Promise<void>) {
7+
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-channel-"));
8+
const stateDir = path.join(fixtureRoot, "state");
9+
const credentialsDir = path.join(stateDir, "credentials");
10+
await fs.mkdir(credentialsDir, {
11+
recursive: true,
12+
mode: 0o700,
13+
});
14+
try {
15+
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, () => fn(stateDir));
16+
} finally {
17+
await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
18+
}
19+
}

0 commit comments

Comments
 (0)