* CLI: add read-only SecretRef inspection * CLI: fix read-only SecretRef status regressions * CLI: preserve read-only SecretRef status fallbacks * Docs: document read-only channel inspection hook * CLI: preserve audit coverage for read-only SecretRefs * CLI: fix read-only status account selection * CLI: fix targeted gateway fallback analysis * CLI: fix Slack HTTP read-only inspection * CLI: align audit credential status checks * CLI: restore Telegram read-only fallback semantics
726 lines
29 KiB
TypeScript
726 lines
29 KiB
TypeScript
import {
|
|
hasConfiguredUnavailableCredentialStatus,
|
|
hasResolvedCredentialValue,
|
|
} from "../channels/account-snapshot-fields.js";
|
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
|
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 {
|
|
isNumericTelegramUserId,
|
|
normalizeTelegramAllowFromEntry,
|
|
} from "../channels/telegram/allow-from.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 { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
|
import { normalizeStringEntries } from "../shared/string-normalization.js";
|
|
import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js";
|
|
import { resolveDmAllowState } from "./dm-policy-shared.js";
|
|
import { isDiscordMutableAllowEntry } from "./mutable-allowlist-detectors.js";
|
|
|
|
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;
|
|
}): void {
|
|
if (!Array.isArray(params.values)) {
|
|
return;
|
|
}
|
|
for (const value of params.values) {
|
|
if (!isDiscordMutableAllowEntry(String(value))) {
|
|
continue;
|
|
}
|
|
const text = String(value).trim();
|
|
if (!text) {
|
|
continue;
|
|
}
|
|
params.target.add(`${params.source}:${text}`);
|
|
}
|
|
}
|
|
|
|
function collectInvalidTelegramAllowFromEntries(params: {
|
|
entries: unknown;
|
|
target: Set<string>;
|
|
}): void {
|
|
if (!Array.isArray(params.entries)) {
|
|
return;
|
|
}
|
|
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 (
|
|
s.includes("dms: open") ||
|
|
s.includes('grouppolicy="open"') ||
|
|
s.includes('dmpolicy="open"')
|
|
) {
|
|
return "critical";
|
|
}
|
|
if (s.includes("allows any") || s.includes("anyone can dm") || s.includes("public")) {
|
|
return "critical";
|
|
}
|
|
if (s.includes("locked") || s.includes("disabled")) {
|
|
return "info";
|
|
}
|
|
return "warn";
|
|
}
|
|
|
|
function dedupeFindings(findings: SecurityAuditFinding[]): SecurityAuditFinding[] {
|
|
const seen = new Set<string>();
|
|
const out: SecurityAuditFinding[] = [];
|
|
for (const finding of findings) {
|
|
const key = [
|
|
finding.checkId,
|
|
finding.severity,
|
|
finding.title,
|
|
finding.detail ?? "",
|
|
finding.remediation ?? "",
|
|
].join("\n");
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
out.push(finding);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hasExplicitProviderAccountConfig(
|
|
cfg: OpenClawConfig,
|
|
provider: string,
|
|
accountId: string,
|
|
): boolean {
|
|
const channel = cfg.channels?.[provider];
|
|
if (!channel || typeof channel !== "object") {
|
|
return false;
|
|
}
|
|
const accounts = (channel as { accounts?: Record<string, unknown> }).accounts;
|
|
if (!accounts || typeof accounts !== "object") {
|
|
return false;
|
|
}
|
|
return Object.hasOwn(accounts, accountId);
|
|
}
|
|
|
|
export async function collectChannelSecurityFindings(params: {
|
|
cfg: OpenClawConfig;
|
|
sourceConfig?: OpenClawConfig;
|
|
plugins: ReturnType<typeof listChannelPlugins>;
|
|
}): Promise<SecurityAuditFinding[]> {
|
|
const findings: SecurityAuditFinding[] = [];
|
|
const sourceConfig = params.sourceConfig ?? params.cfg;
|
|
|
|
const inspectChannelAccount = (
|
|
plugin: (typeof params.plugins)[number],
|
|
cfg: OpenClawConfig,
|
|
accountId: string,
|
|
) =>
|
|
plugin.config.inspectAccount?.(cfg, accountId) ??
|
|
inspectReadOnlyChannelAccount({
|
|
channelId: plugin.id,
|
|
cfg,
|
|
accountId,
|
|
});
|
|
|
|
const asAccountRecord = (value: unknown): Record<string, unknown> | null =>
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
|
|
const resolveChannelAuditAccount = async (
|
|
plugin: (typeof params.plugins)[number],
|
|
accountId: string,
|
|
) => {
|
|
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
|
const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId);
|
|
const sourceInspection = sourceInspectedAccount as {
|
|
enabled?: boolean;
|
|
configured?: boolean;
|
|
} | null;
|
|
const resolvedInspection = resolvedInspectedAccount as {
|
|
enabled?: boolean;
|
|
configured?: boolean;
|
|
} | null;
|
|
const resolvedAccount =
|
|
resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId);
|
|
const useSourceUnavailableAccount = Boolean(
|
|
sourceInspectedAccount &&
|
|
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
|
(!hasResolvedCredentialValue(resolvedAccount) ||
|
|
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
|
|
);
|
|
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
|
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
|
|
const accountRecord = asAccountRecord(account);
|
|
const enabled =
|
|
typeof selectedInspection?.enabled === "boolean"
|
|
? selectedInspection.enabled
|
|
: typeof accountRecord?.enabled === "boolean"
|
|
? accountRecord.enabled
|
|
: plugin.config.isEnabled
|
|
? plugin.config.isEnabled(account, params.cfg)
|
|
: true;
|
|
const configured =
|
|
typeof selectedInspection?.configured === "boolean"
|
|
? selectedInspection.configured
|
|
: typeof accountRecord?.configured === "boolean"
|
|
? accountRecord.configured
|
|
: plugin.config.isConfigured
|
|
? await plugin.config.isConfigured(account, params.cfg)
|
|
: true;
|
|
return { account, enabled, configured };
|
|
};
|
|
|
|
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;
|
|
accountId: string;
|
|
dmPolicy: string;
|
|
allowFrom?: Array<string | number> | null;
|
|
policyPath?: string;
|
|
allowFromPath: string;
|
|
normalizeEntry?: (raw: string) => string;
|
|
}) => {
|
|
const policyPath = input.policyPath ?? `${input.allowFromPath}policy`;
|
|
const { hasWildcard, isMultiUserDm } = await resolveDmAllowState({
|
|
provider: input.provider,
|
|
accountId: input.accountId,
|
|
allowFrom: input.allowFrom,
|
|
normalizeEntry: input.normalizeEntry,
|
|
});
|
|
const dmScope = params.cfg.session?.dmScope ?? "main";
|
|
|
|
if (input.dmPolicy === "open") {
|
|
const allowFromKey = `${input.allowFromPath}allowFrom`;
|
|
findings.push({
|
|
checkId: `channels.${input.provider}.dm.open`,
|
|
severity: "critical",
|
|
title: `${input.label} DMs are open`,
|
|
detail: `${policyPath}="open" allows anyone to DM the bot.`,
|
|
remediation: `Use pairing/allowlist; if you really need open DMs, ensure ${allowFromKey} includes "*".`,
|
|
});
|
|
if (!hasWildcard) {
|
|
findings.push({
|
|
checkId: `channels.${input.provider}.dm.open_invalid`,
|
|
severity: "warn",
|
|
title: `${input.label} DM config looks inconsistent`,
|
|
detail: `"open" requires ${allowFromKey} to include "*".`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (input.dmPolicy === "disabled") {
|
|
findings.push({
|
|
checkId: `channels.${input.provider}.dm.disabled`,
|
|
severity: "info",
|
|
title: `${input.label} DMs are disabled`,
|
|
detail: `${policyPath}="disabled" ignores inbound DMs.`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (dmScope === "main" && isMultiUserDm) {
|
|
findings.push({
|
|
checkId: `channels.${input.provider}.dm.scope_main_multiuser`,
|
|
severity: "warn",
|
|
title: `${input.label} DMs share the main session`,
|
|
detail:
|
|
"Multiple DM senders currently share the main session, which can leak context across users.",
|
|
remediation:
|
|
"Run: " +
|
|
formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') +
|
|
' (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.',
|
|
});
|
|
}
|
|
};
|
|
|
|
for (const plugin of params.plugins) {
|
|
if (!plugin.security) {
|
|
continue;
|
|
}
|
|
const accountIds = plugin.config.listAccountIds(sourceConfig);
|
|
const defaultAccountId = resolveChannelDefaultAccountId({
|
|
plugin,
|
|
cfg: sourceConfig,
|
|
accountIds,
|
|
});
|
|
const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds]));
|
|
|
|
for (const accountId of orderedAccountIds) {
|
|
const hasExplicitAccountPath = hasExplicitProviderAccountConfig(
|
|
sourceConfig,
|
|
plugin.id,
|
|
accountId,
|
|
);
|
|
const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId);
|
|
if (!enabled) {
|
|
continue;
|
|
}
|
|
if (!configured) {
|
|
continue;
|
|
}
|
|
|
|
const accountConfig = (account as { config?: Record<string, unknown> } | null | undefined)
|
|
?.config;
|
|
if (isDangerousNameMatchingEnabled(accountConfig)) {
|
|
const accountNote =
|
|
orderedAccountIds.length > 1 || hasExplicitAccountPath ? ` (account: ${accountId})` : "";
|
|
findings.push({
|
|
checkId: `channels.${plugin.id}.allowFrom.dangerous_name_matching_enabled`,
|
|
severity: "info",
|
|
title: `${plugin.meta.label ?? plugin.id} dangerous name matching is enabled${accountNote}`,
|
|
detail:
|
|
"dangerouslyAllowNameMatching=true re-enables mutable name/email/tag matching for sender authorization. This is a break-glass compatibility mode, not a hardened default.",
|
|
remediation:
|
|
"Prefer stable sender IDs in allowlists, then disable dangerouslyAllowNameMatching.",
|
|
});
|
|
}
|
|
|
|
if (plugin.id === "discord") {
|
|
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`,
|
|
});
|
|
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 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`,
|
|
});
|
|
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,
|
|
});
|
|
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 === "slack") {
|
|
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,
|
|
account,
|
|
});
|
|
if (dmPolicy) {
|
|
await warnDmPolicy({
|
|
label: plugin.meta.label ?? plugin.id,
|
|
provider: plugin.id,
|
|
accountId,
|
|
dmPolicy: dmPolicy.policy,
|
|
allowFrom: dmPolicy.allowFrom,
|
|
policyPath: dmPolicy.policyPath,
|
|
allowFromPath: dmPolicy.allowFromPath,
|
|
normalizeEntry: dmPolicy.normalizeEntry,
|
|
});
|
|
}
|
|
|
|
if (plugin.security.collectWarnings) {
|
|
const warnings = await plugin.security.collectWarnings({
|
|
cfg: params.cfg,
|
|
accountId,
|
|
account,
|
|
});
|
|
for (const message of warnings ?? []) {
|
|
const trimmed = String(message).trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
findings.push({
|
|
checkId: `channels.${plugin.id}.warning.${findings.length + 1}`,
|
|
severity: classifyChannelWarningSeverity(trimmed),
|
|
title: `${plugin.meta.label ?? plugin.id} security warning`,
|
|
detail: trimmed.replace(/^-\s*/, ""),
|
|
});
|
|
}
|
|
}
|
|
|
|
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 storeAllowFrom = await readChannelAllowFromStore(
|
|
"telegram",
|
|
process.env,
|
|
accountId,
|
|
).catch(() => []);
|
|
const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*");
|
|
const invalidTelegramAllowFromEntries = new Set<string>();
|
|
collectInvalidTelegramAllowFromEntries({
|
|
entries: storeAllowFrom,
|
|
target: invalidTelegramAllowFromEntries,
|
|
});
|
|
const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
|
|
? telegramCfg.groupAllowFrom
|
|
: [];
|
|
const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*");
|
|
collectInvalidTelegramAllowFromEntries({
|
|
entries: groupAllowFrom,
|
|
target: invalidTelegramAllowFromEntries,
|
|
});
|
|
const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [];
|
|
collectInvalidTelegramAllowFromEntries({
|
|
entries: dmAllowFrom,
|
|
target: invalidTelegramAllowFromEntries,
|
|
});
|
|
const anyGroupOverride = Boolean(
|
|
groups &&
|
|
Object.values(groups).some((value) => {
|
|
if (!value || typeof value !== "object") {
|
|
return false;
|
|
}
|
|
const group = value as Record<string, unknown>;
|
|
const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
|
|
if (allowFrom.length > 0) {
|
|
collectInvalidTelegramAllowFromEntries({
|
|
entries: allowFrom,
|
|
target: invalidTelegramAllowFromEntries,
|
|
});
|
|
return true;
|
|
}
|
|
const topics = group.topics;
|
|
if (!topics || typeof topics !== "object") {
|
|
return false;
|
|
}
|
|
return Object.values(topics as Record<string, unknown>).some((topicValue) => {
|
|
if (!topicValue || typeof topicValue !== "object") {
|
|
return false;
|
|
}
|
|
const topic = topicValue as Record<string, unknown>;
|
|
const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
|
|
collectInvalidTelegramAllowFromEntries({
|
|
entries: topicAllow,
|
|
target: invalidTelegramAllowFromEntries,
|
|
});
|
|
return topicAllow.length > 0;
|
|
});
|
|
}),
|
|
);
|
|
|
|
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 onboarding 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).",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return dedupeFindings(findings);
|
|
}
|