fix(outbound): restore generic delivery and security seams

This commit is contained in:
Peter Steinberger
2026-04-03 17:55:27 +01:00
parent ab96520bba
commit 856592cf00
57 changed files with 1930 additions and 1517 deletions

View File

@@ -7,97 +7,12 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
import { resolveDmAllowState } from "./dm-policy-shared.js";
const loadAuditChannelDiscordRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.discord.runtime.js"),
({ auditChannelDiscordRuntime }) => auditChannelDiscordRuntime,
);
const loadAuditChannelAllowFromRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.allow-from.runtime.js"),
({ auditChannelAllowFromRuntime }) => auditChannelAllowFromRuntime,
);
const loadAuditChannelTelegramRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.telegram.runtime.js"),
({ auditChannelTelegramRuntime }) => auditChannelTelegramRuntime,
);
const loadAuditChannelZalouserRuntimeModule = createLazyRuntimeSurface(
() => import("./audit-channel.zalouser.runtime.js"),
({ auditChannelZalouserRuntime }) => auditChannelZalouserRuntime,
);
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
return normalizeStringEntries(Array.isArray(list) ? list : undefined);
}
function addDiscordNameBasedEntries(params: {
target: Set<string>;
values: unknown;
source: string;
isDiscordMutableAllowEntry: (value: string) => boolean;
}): void {
if (!Array.isArray(params.values)) {
return;
}
for (const value of params.values) {
if (!params.isDiscordMutableAllowEntry(String(value))) {
continue;
}
const text = String(value).trim();
if (!text) {
continue;
}
params.target.add(`${params.source}:${text}`);
}
}
function addZalouserMutableGroupEntries(params: {
target: Set<string>;
groups: unknown;
source: string;
isZalouserMutableGroupEntry: (value: string) => boolean;
}): void {
if (!params.groups || typeof params.groups !== "object" || Array.isArray(params.groups)) {
return;
}
for (const key of Object.keys(params.groups as Record<string, unknown>)) {
if (!params.isZalouserMutableGroupEntry(key)) {
continue;
}
params.target.add(`${params.source}:${key}`);
}
}
async function collectInvalidTelegramAllowFromEntries(params: {
entries: unknown;
target: Set<string>;
}): Promise<void> {
if (!Array.isArray(params.entries)) {
return;
}
const { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } =
await loadAuditChannelTelegramRuntimeModule();
for (const entry of params.entries) {
const normalized = normalizeTelegramAllowFromEntry(entry);
if (!normalized || normalized === "*") {
continue;
}
if (!isNumericTelegramUserId(normalized)) {
params.target.add(normalized);
}
}
}
function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity {
const s = message.toLowerCase();
if (
@@ -277,19 +192,6 @@ export async function collectChannelSecurityFindings(params: {
return { account, enabled, configured, diagnostics };
};
const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => {
if (value === true) {
return true;
}
if (value === false) {
return false;
}
if (value === "auto") {
return "auto";
}
return undefined;
};
const warnDmPolicy = async (input: {
label: string;
provider: ChannelId;
@@ -411,318 +313,6 @@ export async function collectChannelSecurityFindings(params: {
});
}
if (
plugin.id === "synology-chat" &&
(account as { dangerouslyAllowNameMatching?: unknown } | null)
?.dangerouslyAllowNameMatching === true
) {
const accountNote = formatChannelAccountNote({
orderedAccountIds,
hasExplicitAccountPath,
accountId,
});
findings.push({
checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
severity: "info",
title: `Synology Chat dangerous name matching is enabled${accountNote}`,
detail:
"dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
remediation:
"Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching.",
});
}
if (plugin.id === "discord") {
const { isDiscordMutableAllowEntry } = await loadAuditChannelDiscordRuntimeModule();
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const discordCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
const storeAllowFrom = await readChannelAllowFromStore(
"discord",
process.env,
accountId,
).catch(() => []);
const discordNameBasedAllowEntries = new Set<string>();
const discordPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
? `channels.discord.accounts.${accountId}`
: "channels.discord";
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: discordCfg.allowFrom,
source: `${discordPathPrefix}.allowFrom`,
isDiscordMutableAllowEntry,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
source: `${discordPathPrefix}.dm.allowFrom`,
isDiscordMutableAllowEntry,
});
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: storeAllowFrom,
source: "~/.openclaw/credentials/discord-allowFrom.json",
isDiscordMutableAllowEntry,
});
const discordGuildEntries =
(discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
for (const [guildKey, guildValue] of Object.entries(discordGuildEntries)) {
if (!guildValue || typeof guildValue !== "object") {
continue;
}
const guild = guildValue as Record<string, unknown>;
addDiscordNameBasedEntries({
target: discordNameBasedAllowEntries,
values: guild.users,
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
isDiscordMutableAllowEntry,
});
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`,
isDiscordMutableAllowEntry,
});
}
}
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,
});
const slashEnabled = nativeEnabled || nativeSkillsEnabled;
if (slashEnabled) {
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const guildEntries = discordGuildEntries;
const guildsConfigured = Object.keys(guildEntries).length > 0;
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
if (!guild || typeof guild !== "object") {
return false;
}
const g = guild as Record<string, unknown>;
if (Array.isArray(g.users) && g.users.length > 0) {
return true;
}
const channels = g.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 c = channel as Record<string, unknown>;
return Array.isArray(c.users) && c.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;
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (
!useAccessGroups &&
groupPolicy !== "disabled" &&
guildsConfigured &&
!hasAnyUserAllowlist
) {
findings.push({
checkId: "channels.discord.commands.native.unrestricted",
severity: "critical",
title: "Discord slash commands are unrestricted",
detail:
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
remediation:
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
});
} else if (
useAccessGroups &&
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.",
});
}
}
}
if (plugin.id === "zalouser") {
const { isZalouserMutableGroupEntry } = await loadAuditChannelZalouserRuntimeModule();
const zalouserCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
const zalouserPathPrefix =
orderedAccountIds.length > 1 || hasExplicitAccountPath
? `channels.zalouser.accounts.${accountId}`
: "channels.zalouser";
const mutableGroupEntries = new Set<string>();
addZalouserMutableGroupEntries({
target: mutableGroupEntries,
groups: zalouserCfg.groups,
source: `${zalouserPathPrefix}.groups`,
isZalouserMutableGroupEntry,
});
if (mutableGroupEntries.size > 0) {
const examples = Array.from(mutableGroupEntries).slice(0, 5);
const more =
mutableGroupEntries.size > examples.length
? ` (+${mutableGroupEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.zalouser.groups.mutable_entries",
severity: dangerousNameMatchingEnabled ? "info" : "warn",
title: dangerousNameMatchingEnabled
? "Zalouser group routing uses break-glass name matching"
: "Zalouser group routing contains mutable group entries",
detail: dangerousNameMatchingEnabled
? "Zalouser group-name routing 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}.`
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
});
}
}
if (plugin.id === "slack") {
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const slackCfg =
(account as { config?: Record<string, unknown>; dm?: Record<string, unknown> } | null)
?.config ?? ({} as Record<string, unknown>);
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 ||
(slackCfg.slashCommand as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.slack.commands.slash.useAccessGroups_off",
severity: "critical",
title: "Slack slash commands bypass access groups",
detail:
"Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
} else {
const allowFromRaw = (
account as
| { config?: { allowFrom?: unknown }; dm?: { allowFrom?: unknown } }
| null
| undefined
)?.config?.allowFrom;
const legacyAllowFromRaw = (
account as { dm?: { allowFrom?: unknown } } | null | undefined
)?.dm?.allowFrom;
const allowFrom = Array.isArray(allowFromRaw)
? allowFromRaw
: Array.isArray(legacyAllowFromRaw)
? legacyAllowFromRaw
: [];
const storeAllowFrom = await readChannelAllowFromStore(
"slack",
process.env,
accountId,
).catch(() => []);
const ownerAllowFromConfigured =
normalizeAllowFromList([...allowFrom, ...storeAllowFrom]).length > 0;
const channels = (slackCfg.channels as Record<string, unknown> | undefined) ?? {};
const hasAnyChannelUsersAllowlist = Object.values(channels).some((value) => {
if (!value || typeof value !== "object") {
return false;
}
const channel = value as Record<string, unknown>;
return Array.isArray(channel.users) && channel.users.length > 0;
});
if (!ownerAllowFromConfigured && !hasAnyChannelUsersAllowlist) {
findings.push({
checkId: "channels.slack.commands.slash.no_allowlists",
severity: "warn",
title: "Slack slash commands have no allowlists",
detail:
"Slack slash/native commands are enabled, but neither an owner allowFrom list nor any channels.<id>.users allowlist is configured; /… commands will be rejected for everyone.",
remediation:
"Approve yourself via pairing (recommended), or set channels.slack.allowFrom and/or channels.slack.channels.<id>.users.",
});
}
}
}
}
const dmPolicy = plugin.security.resolveDmPolicy?.({
cfg: params.cfg,
accountId,
@@ -760,145 +350,19 @@ export async function collectChannelSecurityFindings(params: {
});
}
}
if (plugin.id !== "telegram") {
continue;
}
const allowTextCommands = params.cfg.commands?.text !== false;
if (!allowTextCommands) {
continue;
}
const telegramCfg =
(account as { config?: Record<string, unknown> } | null)?.config ??
({} as Record<string, unknown>);
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
const groupPolicy =
(telegramCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
const groups = telegramCfg.groups as Record<string, unknown> | undefined;
const groupsConfigured = Boolean(groups) && Object.keys(groups ?? {}).length > 0;
const groupAccessPossible =
groupPolicy === "open" || (groupPolicy === "allowlist" && groupsConfigured);
if (!groupAccessPossible) {
continue;
}
const { readChannelAllowFromStore } = await loadAuditChannelAllowFromRuntimeModule();
const storeAllowFrom = await readChannelAllowFromStore(
"telegram",
process.env,
accountId,
).catch(() => []);
const storeHasWildcard = storeAllowFrom.some((value) => String(value).trim() === "*");
const invalidTelegramAllowFromEntries = new Set<string>();
await collectInvalidTelegramAllowFromEntries({
entries: storeAllowFrom,
target: invalidTelegramAllowFromEntries,
});
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
? telegramCfg.groupAllowFrom
: [];
const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*");
await collectInvalidTelegramAllowFromEntries({
entries: groupAllowFrom,
target: invalidTelegramAllowFromEntries,
});
const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [];
await collectInvalidTelegramAllowFromEntries({
entries: dmAllowFrom,
target: invalidTelegramAllowFromEntries,
});
let anyGroupOverride = false;
if (groups) {
for (const value of Object.values(groups)) {
if (!value || typeof value !== "object") {
continue;
}
const group = value as Record<string, unknown>;
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
if (allowFrom.length > 0) {
anyGroupOverride = true;
await collectInvalidTelegramAllowFromEntries({
entries: allowFrom,
target: invalidTelegramAllowFromEntries,
});
}
const topics = group.topics;
if (!topics || typeof topics !== "object") {
continue;
}
for (const topicValue of Object.values(topics as Record<string, unknown>)) {
if (!topicValue || typeof topicValue !== "object") {
continue;
}
const topic = topicValue as Record<string, unknown>;
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
if (topicAllow.length > 0) {
anyGroupOverride = true;
}
await collectInvalidTelegramAllowFromEntries({
entries: topicAllow,
target: invalidTelegramAllowFromEntries,
});
}
if (plugin.security.collectAuditFindings) {
const auditFindings = await plugin.security.collectAuditFindings({
cfg: params.cfg,
sourceConfig,
accountId,
account,
orderedAccountIds,
hasExplicitAccountPath,
});
for (const finding of auditFindings ?? []) {
findings.push(finding);
}
}
const hasAnySenderAllowlist =
storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
if (invalidTelegramAllowFromEntries.size > 0) {
const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
const more =
invalidTelegramAllowFromEntries.size > examples.length
? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
: "";
findings.push({
checkId: "channels.telegram.allowFrom.invalid_entries",
severity: "warn",
title: "Telegram allowlist contains non-numeric entries",
detail:
"Telegram sender authorization requires numeric Telegram user IDs. " +
`Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
remediation:
"Replace @username entries with numeric Telegram user IDs (use setup to resolve), then re-run the audit.",
});
}
if (storeHasWildcard || groupAllowFromHasWildcard) {
findings.push({
checkId: "channels.telegram.groups.allowFrom.wildcard",
severity: "critical",
title: "Telegram group allowlist contains wildcard",
detail:
'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
remediation:
'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
});
continue;
}
if (!hasAnySenderAllowlist) {
const providerSetting = (telegramCfg.commands as { nativeSkills?: unknown } | undefined)
// oxlint-disable-next-line typescript/no-explicit-any
?.nativeSkills as any;
const skillsEnabled = resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting,
globalSetting: params.cfg.commands?.nativeSkills,
});
findings.push({
checkId: "channels.telegram.groups.allowFrom.missing",
severity: "critical",
title: "Telegram group commands have no sender allowlist",
detail:
`Telegram group access is enabled but no sender allowlist is configured; this allows any group member to invoke /… commands` +
(skillsEnabled ? " (including skill commands)." : "."),
remediation:
"Approve yourself via pairing (recommended), or set channels.telegram.groupAllowFrom (or per-group groups.<id>.allowFrom).",
});
}
}
}

View File

@@ -15,13 +15,14 @@ import { resolveSkillSource } from "../agents/skills/source.js";
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
import { formatCliCommand } from "../cli/command-format.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { resolveNativeSkillsEnabled } from "../config/commands.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
import { resolveOAuthDir } from "../config/paths.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -118,6 +119,85 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string):
.join("\n");
}
function readChannelCommandSetting(
cfg: OpenClawConfig,
channelId: string,
key: "native" | "nativeSkills",
): unknown {
const channelCfg = cfg.channels?.[channelId as keyof NonNullable<OpenClawConfig["channels"]>];
if (!channelCfg || typeof channelCfg !== "object" || Array.isArray(channelCfg)) {
return undefined;
}
const commands = (channelCfg as { commands?: unknown }).commands;
if (!commands || typeof commands !== "object" || Array.isArray(commands)) {
return undefined;
}
return (commands as Record<string, unknown>)[key];
}
async function isChannelPluginConfigured(
cfg: OpenClawConfig,
plugin: ReturnType<typeof listChannelPlugins>[number],
): Promise<boolean> {
const accountIds = plugin.config.listAccountIds(cfg);
const candidates = accountIds.length > 0 ? accountIds : [undefined];
for (const accountId of candidates) {
const inspected =
plugin.config.inspectAccount?.(cfg, accountId) ??
(await inspectReadOnlyChannelAccount({
channelId: plugin.id,
cfg,
accountId,
}));
const inspectedRecord =
inspected && typeof inspected === "object" && !Array.isArray(inspected)
? (inspected as Record<string, unknown>)
: null;
let resolvedAccount: unknown = inspected;
if (!resolvedAccount) {
try {
resolvedAccount = plugin.config.resolveAccount(cfg, accountId);
} catch {
resolvedAccount = null;
}
}
let enabled =
typeof inspectedRecord?.enabled === "boolean"
? inspectedRecord.enabled
: resolvedAccount != null;
if (
typeof inspectedRecord?.enabled !== "boolean" &&
resolvedAccount != null &&
plugin.config.isEnabled
) {
try {
enabled = plugin.config.isEnabled(resolvedAccount, cfg);
} catch {
enabled = false;
}
}
let configured =
typeof inspectedRecord?.configured === "boolean"
? inspectedRecord.configured
: resolvedAccount != null;
if (
typeof inspectedRecord?.configured !== "boolean" &&
resolvedAccount != null &&
plugin.config.isConfigured
) {
try {
configured = await plugin.config.isConfigured(resolvedAccount, cfg);
} catch {
configured = false;
}
}
if (enabled && configured) {
return true;
}
}
return false;
}
async function listInstalledPluginDirs(params: {
stateDir: string;
onReadError?: (error: unknown) => void;
@@ -544,75 +624,29 @@ export async function collectPluginsTrustFindings(params: {
const allow = params.cfg.plugins?.allow;
const allowConfigured = Array.isArray(allow) && allow.length > 0;
if (!allowConfigured) {
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
const hasSecretInput = (value: unknown) =>
hasConfiguredSecretInput(value, params.cfg.secrets?.defaults);
const hasAccountStringKey = (account: unknown, key: string) =>
Boolean(
account &&
typeof account === "object" &&
hasString((account as Record<string, unknown>)[key]),
);
const hasAccountSecretInputKey = (account: unknown, key: string) =>
Boolean(
account &&
typeof account === "object" &&
hasSecretInput((account as Record<string, unknown>)[key]),
);
const discordConfigured =
hasSecretInput(params.cfg.channels?.discord?.token) ||
Boolean(
params.cfg.channels?.discord?.accounts &&
Object.values(params.cfg.channels.discord.accounts).some((a) =>
hasAccountSecretInputKey(a, "token"),
),
) ||
hasString(process.env.DISCORD_BOT_TOKEN);
const telegramConfigured =
hasSecretInput(params.cfg.channels?.telegram?.botToken) ||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
Boolean(
params.cfg.channels?.telegram?.accounts &&
Object.values(params.cfg.channels.telegram.accounts).some(
(a) => hasAccountSecretInputKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
),
) ||
hasString(process.env.TELEGRAM_BOT_TOKEN);
const slackConfigured =
hasSecretInput(params.cfg.channels?.slack?.botToken) ||
hasSecretInput(params.cfg.channels?.slack?.appToken) ||
Boolean(
params.cfg.channels?.slack?.accounts &&
Object.values(params.cfg.channels.slack.accounts).some(
(a) =>
hasAccountSecretInputKey(a, "botToken") || hasAccountSecretInputKey(a, "appToken"),
),
) ||
hasString(process.env.SLACK_BOT_TOKEN) ||
hasString(process.env.SLACK_APP_TOKEN);
const skillCommandsLikelyExposed =
(discordConfigured &&
resolveNativeSkillsEnabled({
providerId: "discord",
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(telegramConfigured &&
resolveNativeSkillsEnabled({
providerId: "telegram",
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
})) ||
(slackConfigured &&
resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
globalSetting: params.cfg.commands?.nativeSkills,
}));
const skillCommandsLikelyExposed = (
await Promise.all(
listChannelPlugins().map(async (plugin) => {
if (
plugin.capabilities.nativeCommands !== true &&
plugin.commands?.nativeSkillsAutoEnabled !== true
) {
return false;
}
if (!(await isChannelPluginConfigured(params.cfg, plugin))) {
return false;
}
return resolveNativeSkillsEnabled({
providerId: plugin.id,
providerSetting: readChannelCommandSetting(params.cfg, plugin.id, "nativeSkills") as
| "auto"
| boolean
| undefined,
globalSetting: params.cfg.commands?.nativeSkills,
});
}),
)
).some(Boolean);
findings.push({
checkId: "plugins.extensions_no_allowlist",

View File

@@ -2,9 +2,16 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectDiscordSecurityAuditFindings } from "../../extensions/discord/api.js";
import { collectSlackSecurityAuditFindings } from "../../extensions/slack/api.js";
import { collectSynologyChatSecurityAuditFindings } from "../../extensions/synology-chat/api.js";
import { collectTelegramSecurityAuditFindings } from "../../extensions/telegram/api.js";
import { collectZalouserSecurityAuditFindings } from "../../extensions/zalouser/api.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { saveExecApprovals } from "../infra/exec-approvals.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
import { runSecurityAudit } from "./audit.js";
@@ -39,7 +46,47 @@ function stubChannelPlugin(params: {
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 ??
(params.id === "discord"
? (collectDiscordSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "slack"
? (collectSlackSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "synology-chat"
? (collectSynologyChatSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "telegram"
? (collectTelegramSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: params.id === "zalouser"
? (collectZalouserSecurityAuditFindings as NonNullable<
ChannelPlugin["security"]
>["collectAuditFindings"])
: undefined);
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: {
@@ -52,7 +99,12 @@ function stubChannelPlugin(params: {
capabilities: {
chatTypes: ["direct", "group"],
},
security: {},
...(defaultCommands ? { commands: defaultCommands } : {}),
security: defaultCollectAuditFindings
? {
collectAuditFindings: defaultCollectAuditFindings,
}
: {},
config: {
listAccountIds:
params.listAccountIds ??
@@ -78,14 +130,14 @@ function stubChannelPlugin(params: {
const config = account?.config ?? {};
return {
accountId: resolvedAccountId,
enabled: params.isEnabled?.(account, cfg) ?? true,
configured: params.isConfigured?.(account, cfg) ?? true,
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) ?? true,
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? true,
isEnabled: (account, cfg) => params.isEnabled?.(account, cfg) ?? channelConfigured(cfg),
isConfigured: (account, cfg) => params.isConfigured?.(account, cfg) ?? channelConfigured(cfg),
},
};
}
@@ -180,6 +232,14 @@ const synologyChatPlugin = stubChannelPlugin({
},
});
const BASE_AUDIT_CHANNEL_PLUGINS = [
discordPlugin,
slackPlugin,
telegramPlugin,
zalouserPlugin,
synologyChatPlugin,
] satisfies ChannelPlugin[];
function successfulProbeResult(url: string) {
return {
ok: true,
@@ -202,12 +262,14 @@ async function audit(
saveExecApprovals({ version: 1, agents: {} });
}
const { preserveExecApprovals: _preserveExecApprovals, ...options } = extra ?? {};
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
...options,
});
return withActiveAuditChannelPlugins(options.plugins ?? BASE_AUDIT_CHANNEL_PLUGINS, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: false,
...options,
}),
);
}
async function runAuditCases<T>(
@@ -299,12 +361,33 @@ async function runChannelSecurityAudit(
cfg: OpenClawConfig,
plugins: ChannelPlugin[],
): Promise<SecurityAuditReport> {
return runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
});
return withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
}
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 runInstallMetadataAudit(
@@ -2191,12 +2274,14 @@ describe("security audit", () => {
},
];
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
});
const res = await withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
expect(res.findings).toEqual(
expect.arrayContaining([
@@ -2255,12 +2340,14 @@ describe("security audit", () => {
] as const;
await runChannelSecurityStateCases(cases, async (testCase) => {
const res = await runSecurityAudit({
config: testCase.cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
});
const res = await withActiveAuditChannelPlugins([discordPlugin], () =>
runSecurityAudit({
config: testCase.cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [discordPlugin],
}),
);
expect(
res.findings.some(
@@ -2463,13 +2550,16 @@ describe("security audit", () => {
] as const;
await runChannelSecurityStateCases(cases, async (testCase) => {
const res = await runSecurityAudit({
config: testCase.resolvedConfig,
sourceConfig: testCase.sourceConfig,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [testCase.plugin(testCase.sourceConfig)],
});
const plugins = [testCase.plugin(testCase.sourceConfig)];
const res = await withActiveAuditChannelPlugins(plugins, () =>
runSecurityAudit({
config: testCase.resolvedConfig,
sourceConfig: testCase.sourceConfig,
includeFilesystem: false,
includeChannelSecurity: true,
plugins,
}),
);
expect(res.findings, testCase.name).toEqual(
expect.arrayContaining([
@@ -2500,12 +2590,14 @@ describe("security audit", () => {
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [plugin],
});
const res = await withActiveAuditChannelPlugins([plugin], () =>
runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [plugin],
}),
);
const finding = res.findings.find(
(entry) => entry.checkId === "channels.zalouser.account.read_only_resolution",
@@ -2765,12 +2857,14 @@ describe("security audit", () => {
},
};
const res = await runSecurityAudit({
config: cfg,
includeFilesystem: false,
includeChannelSecurity: true,
plugins: [pluginWithProtoDefaultAccount],
});
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",

View File

@@ -120,6 +120,9 @@ let auditDeepModulePromise: Promise<typeof import("./audit.deep.runtime.js")> |
let auditChannelModulePromise:
| Promise<typeof import("./audit-channel.collect.runtime.js")>
| undefined;
let pluginRegistryLoaderModulePromise:
| Promise<typeof import("../plugins/runtime/runtime-registry-loader.js")>
| undefined;
let gatewayProbeDepsPromise:
| Promise<{
buildGatewayConnectionDetails: typeof import("../gateway/call.js").buildGatewayConnectionDetails;
@@ -148,6 +151,11 @@ async function loadAuditChannelModule() {
return await auditChannelModulePromise;
}
async function loadPluginRegistryLoaderModule() {
pluginRegistryLoaderModulePromise ??= import("../plugins/runtime/runtime-registry-loader.js");
return await pluginRegistryLoaderModulePromise;
}
async function loadGatewayProbeDeps() {
gatewayProbeDepsPromise ??= Promise.all([
import("../gateway/call.js"),
@@ -1455,6 +1463,14 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
context.includeChannelSecurity &&
(context.plugins !== undefined || hasPotentialConfiguredChannels(cfg, env));
if (shouldAuditChannelSecurity) {
if (context.plugins === undefined) {
(await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({
scope: "configured-channels",
config: cfg,
activationSourceConfig: context.sourceConfig,
env,
});
}
const channelPlugins = context.plugins ?? (await loadChannelPlugins()).listChannelPlugins();
const { collectChannelSecurityFindings } = await loadAuditChannelModule();
findings.push(