mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 00:31:22 +00:00
fix(outbound): restore generic delivery and security seams
This commit is contained in:
@@ -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).",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user