mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
ca8610151a
* fix(discord): warn on broad group member access * test(discord): type broad-member audit cases
258 lines
9.9 KiB
TypeScript
258 lines
9.9 KiB
TypeScript
// Discord plugin module implements security audit behavior.
|
|
import { coerceNativeSetting, normalizeAllowFromList } from "openclaw/plugin-sdk/channel-policy";
|
|
import type {
|
|
DiscordGuildChannelConfig,
|
|
DiscordGuildEntry,
|
|
} from "openclaw/plugin-sdk/config-contracts";
|
|
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
|
import {
|
|
resolveNativeCommandsEnabled,
|
|
resolveNativeSkillsEnabled,
|
|
} from "openclaw/plugin-sdk/native-command-config-runtime";
|
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import type { ResolvedDiscordAccount } from "./accounts.js";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
import { isDiscordMutableAllowEntry } from "./security-doctor.js";
|
|
|
|
function isWildcardEntry(value: unknown): boolean {
|
|
return String(value).trim() === "*";
|
|
}
|
|
|
|
function hasNarrowMemberRestriction(
|
|
guild: DiscordGuildEntry,
|
|
channel?: DiscordGuildChannelConfig,
|
|
): boolean {
|
|
const users = channel?.users ?? guild.users ?? [];
|
|
const roles = channel?.roles ?? guild.roles ?? [];
|
|
if ([...users, ...roles].some((entry) => isWildcardEntry(entry))) {
|
|
return false;
|
|
}
|
|
return users.length > 0 || roles.length > 0;
|
|
}
|
|
|
|
function listBroadMemberTargetPaths(params: {
|
|
discordCfg: ResolvedDiscordAccount["config"];
|
|
pathPrefix: string;
|
|
}): string[] {
|
|
const paths: string[] = [];
|
|
for (const [guildKey, guild] of Object.entries(params.discordCfg.guilds ?? {})) {
|
|
const guildPath = `${params.pathPrefix}.guilds.${guildKey}`;
|
|
const channels = Object.entries(guild.channels ?? {});
|
|
if (channels.length === 0) {
|
|
if (!hasNarrowMemberRestriction(guild)) {
|
|
paths.push(guildPath);
|
|
}
|
|
continue;
|
|
}
|
|
for (const [channelKey, channel] of channels) {
|
|
if (channel.enabled === false || hasNarrowMemberRestriction(guild, channel)) {
|
|
continue;
|
|
}
|
|
paths.push(`${guildPath}.channels.${channelKey}`);
|
|
}
|
|
}
|
|
return paths.toSorted();
|
|
}
|
|
|
|
function addDiscordNameBasedEntries(params: {
|
|
target: Set<string>;
|
|
values: unknown;
|
|
source: string;
|
|
}) {
|
|
if (!Array.isArray(params.values)) {
|
|
return;
|
|
}
|
|
for (const value of params.values) {
|
|
if (!isDiscordMutableAllowEntry(String(value))) {
|
|
continue;
|
|
}
|
|
const text = normalizeOptionalString(String(value)) ?? "";
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
params.target.add(`${params.source}:${text}`);
|
|
}
|
|
}
|
|
|
|
export async function collectDiscordSecurityAuditFindings(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId?: string | null;
|
|
account: ResolvedDiscordAccount;
|
|
orderedAccountIds: string[];
|
|
hasExplicitAccountPath: boolean;
|
|
}) {
|
|
const findings: Array<{
|
|
checkId: string;
|
|
severity: "info" | "warn" | "critical";
|
|
title: string;
|
|
detail: string;
|
|
remediation?: string;
|
|
}> = [];
|
|
const discordCfg = params.account.config ?? {};
|
|
const accountId =
|
|
normalizeOptionalString(params.accountId) ?? params.account.accountId ?? "default";
|
|
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
|
|
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
|
|
() => [],
|
|
);
|
|
const discordNameBasedAllowEntries = new Set<string>();
|
|
const discordPathPrefix =
|
|
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
|
? `channels.discord.accounts.${accountId}`
|
|
: "channels.discord";
|
|
|
|
const effectiveGroupPolicy =
|
|
discordCfg.groupPolicy ?? params.cfg.channels?.defaults?.groupPolicy ?? "allowlist";
|
|
if (effectiveGroupPolicy === "allowlist") {
|
|
const broadMemberPaths = listBroadMemberTargetPaths({
|
|
discordCfg,
|
|
pathPrefix: discordPathPrefix,
|
|
});
|
|
if (broadMemberPaths.length > 0) {
|
|
findings.push({
|
|
checkId: "channels.discord.allowlisted_groups.broad_members",
|
|
severity: "warn",
|
|
title: "Discord allowlisted groups have broad member access",
|
|
detail:
|
|
`These allowlisted Discord targets have no effective users or roles restriction:\n${broadMemberPaths.map((path) => `- ${path}`).join("\n")}\n` +
|
|
'groupPolicy="allowlist" limits guilds/channels, but all members of a listed target can still trigger the agent.',
|
|
remediation:
|
|
"Add users or roles restrictions at each listed guild/channel when only specific members should trigger the agent.",
|
|
});
|
|
}
|
|
}
|
|
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: discordCfg.allowFrom,
|
|
source: `${discordPathPrefix}.allowFrom`,
|
|
});
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
|
|
source: `${discordPathPrefix}.dm.allowFrom`,
|
|
});
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: storeAllowFrom,
|
|
source: "~/.openclaw/credentials/discord-allowFrom.json",
|
|
});
|
|
|
|
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
|
|
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
|
|
if (!guildValue || typeof guildValue !== "object") {
|
|
continue;
|
|
}
|
|
const guild = guildValue as Record<string, unknown>;
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: guild.users,
|
|
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
|
|
});
|
|
const channels = guild.channels;
|
|
if (!channels || typeof channels !== "object") {
|
|
continue;
|
|
}
|
|
for (const [channelKey, channelValue] of Object.entries(channels as Record<string, unknown>)) {
|
|
if (!channelValue || typeof channelValue !== "object") {
|
|
continue;
|
|
}
|
|
const channel = channelValue as Record<string, unknown>;
|
|
addDiscordNameBasedEntries({
|
|
target: discordNameBasedAllowEntries,
|
|
values: channel.users,
|
|
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (discordNameBasedAllowEntries.size > 0) {
|
|
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
|
|
const more =
|
|
discordNameBasedAllowEntries.size > examples.length
|
|
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
|
|
: "";
|
|
findings.push({
|
|
checkId: "channels.discord.allowFrom.name_based_entries",
|
|
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
|
title: dangerousNameMatchingEnabled
|
|
? "Discord allowlist uses break-glass name/tag matching"
|
|
: "Discord allowlist contains name or tag entries",
|
|
detail: dangerousNameMatchingEnabled
|
|
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
|
`Found: ${examples.join(", ")}${more}.`
|
|
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
|
`Found: ${examples.join(", ")}${more}.`,
|
|
remediation: dangerousNameMatchingEnabled
|
|
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
|
|
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
|
|
});
|
|
}
|
|
|
|
const nativeEnabled = resolveNativeCommandsEnabled({
|
|
providerId: "discord",
|
|
providerSetting: coerceNativeSetting(
|
|
(discordCfg.commands as { native?: unknown } | undefined)?.native,
|
|
),
|
|
globalSetting: params.cfg.commands?.native,
|
|
});
|
|
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
|
providerId: "discord",
|
|
providerSetting: coerceNativeSetting(
|
|
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
|
),
|
|
globalSetting: params.cfg.commands?.nativeSkills,
|
|
});
|
|
if (!nativeEnabled && !nativeSkillsEnabled) {
|
|
return findings;
|
|
}
|
|
|
|
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
|
const groupPolicy =
|
|
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
|
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
|
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
|
|
if (!guild || typeof guild !== "object") {
|
|
return false;
|
|
}
|
|
const record = guild as Record<string, unknown>;
|
|
if (Array.isArray(record.users) && record.users.length > 0) {
|
|
return true;
|
|
}
|
|
const channels = record.channels;
|
|
if (!channels || typeof channels !== "object") {
|
|
return false;
|
|
}
|
|
return Object.values(channels as Record<string, unknown>).some((channel) => {
|
|
if (!channel || typeof channel !== "object") {
|
|
return false;
|
|
}
|
|
const channelRecord = channel as Record<string, unknown>;
|
|
return Array.isArray(channelRecord.users) && channelRecord.users.length > 0;
|
|
});
|
|
});
|
|
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
|
|
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
|
|
const ownerAllowFromConfigured =
|
|
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
|
|
if (
|
|
groupPolicy !== "disabled" &&
|
|
guildsConfigured &&
|
|
!ownerAllowFromConfigured &&
|
|
!hasAnyUserAllowlist
|
|
) {
|
|
findings.push({
|
|
checkId: "channels.discord.commands.native.no_allowlists",
|
|
severity: "warn",
|
|
title: "Discord slash commands have no allowlists",
|
|
detail:
|
|
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
|
|
remediation:
|
|
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
|
});
|
|
}
|
|
|
|
return findings;
|
|
}
|