import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import type { ResolvedMattermostAccount } from "./mattermost/accounts.js"; import type { MattermostAccountConfig, MattermostConfig } from "./types.js"; import type { OpenClawConfig } from "./runtime-api.js"; export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; title: string; detail: string; remediation?: string; }; function normalizeAllowFromList(list: Array | undefined | null): string[] { if (!Array.isArray(list)) { return []; } return list.map((value) => String(value).trim()).filter(Boolean); } function coerceNativeSetting(value: unknown): boolean | "auto" | undefined { if (value === true || value === false || value === "auto") { return value; } return undefined; } function isHttpsUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === "https:"; } catch { return false; } } function isValidMattermostId(id: string): boolean { // Mattermost IDs are 26-character alphanumeric strings const normalized = id .replace(/^(mattermost|user):/i, "") .replace(/^@/, "") .trim() .toLowerCase(); return /^[a-z0-9]{26}$/.test(normalized); } function isMutableAllowEntry(raw: string): boolean { const text = raw.trim(); if (!text || text === "*") { return false; } if (isValidMattermostId(text)) { return false; } return true; } function hasEnvVarReference(value: string | undefined): boolean { if (!value) return false; // Check for common env var patterns: $VAR, ${VAR}, %VAR% return /\$\w+|\$\{[^}]+\}|%[^%]+%/.test(value); } function hasHardcodedSecret(value: string | undefined): boolean { if (!value) return false; // Mattermost tokens are typically 26+ character alphanumeric // Pattern: looks like a token but not an env reference const trimmed = value.trim(); if (trimmed.length < 20) return false; // Check if it looks like a token (alphanumeric with possible hyphens/underscores) if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) return false; // Check for common env var patterns that would indicate it's not hardcoded if (hasEnvVarReference(trimmed)) return false; return true; } function sanitizeForLog(value: string): string { return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); } /** * Collect security audit findings for Mattermost configuration. * This function performs comprehensive security checks including: * - PAT (Personal Access Token) security (env var usage, no hardcoding) * - HTTPS enforcement verification * - Input validation audit * - File download restrictions check * - Token permission checks * - Config migration detection * - Warnings for insecure configurations */ export async function collectMattermostSecurityAuditFindings(params: { cfg: OpenClawConfig; accountId?: string | null; account: ResolvedMattermostAccount; }): Promise { const findings: SecurityAuditFinding[] = []; const mmCfg = params.account.config ?? {}; const accountId = params.accountId?.trim() || params.account.accountId || "default"; // ===== 1. PAT SECURITY CHECKS ===== // Check 1.1: Bot token should use environment variables if (params.account.botToken && params.account.botTokenSource === "config") { const botToken = params.account.botToken; if (hasHardcodedSecret(botToken)) { findings.push({ checkId: "channels.mattermost.pat.hardcoded_token", severity: "critical", title: "Hardcoded Mattermost bot token detected", detail: `The bot token for account "${accountId}" appears to be hardcoded in configuration. Hardcoded credentials pose a security risk and may be exposed in version control or logs.`, remediation: "Move the token to the MATTERMOST_BOT_TOKEN environment variable or use a secret management system.", }); } else if (botToken.length < 20) { findings.push({ checkId: "channels.mattermost.pat.short_token", severity: "warn", title: "Short Mattermost bot token detected", detail: `The bot token for account "${accountId}" appears unusually short (${botToken.length} chars). This may indicate an invalid or incomplete token.`, remediation: "Verify the bot token is complete and valid.", }); } } // Check 1.2: No bot token configured if (!params.account.botToken) { findings.push({ checkId: "channels.mattermost.pat.missing_token", severity: "critical", title: "No Mattermost bot token configured", detail: `Account "${accountId}" has no bot token configured. The bot will not be able to authenticate with the Mattermost server.`, remediation: "Set the MATTERMOST_BOT_TOKEN environment variable or configure channels.mattermost.botToken.", }); } // ===== 2. HTTPS ENFORCEMENT CHECK ===== // Check 2.1: Base URL should use HTTPS if (params.account.baseUrl) { if (!isHttpsUrl(params.account.baseUrl)) { findings.push({ checkId: "channels.mattermost.https.insecure_url", severity: "critical", title: "Mattermost base URL uses HTTP (insecure)", detail: `The base URL "${sanitizeForLog(params.account.baseUrl)}" for account "${accountId}" uses HTTP instead of HTTPS. This exposes all communications (including authentication tokens) to interception.`, remediation: "Change the baseUrl to use HTTPS, e.g., https://chat.example.com", }); } } else { findings.push({ checkId: "channels.mattermost.https.missing_url", severity: "critical", title: "No Mattermost base URL configured", detail: `Account "${accountId}" has no base URL configured. The bot will not be able to connect to the Mattermost server.`, remediation: "Set the MATTERMOST_URL environment variable or configure channels.mattermost.baseUrl with a HTTPS URL.", }); } // ===== 3. INPUT VALIDATION AUDIT ===== // Check 3.1: Validate interaction callback base URL const interactions = mmCfg.interactions as { callbackBaseUrl?: string; allowedSourceIps?: string[] } | undefined; if (interactions?.callbackBaseUrl) { if (!isHttpsUrl(interactions.callbackBaseUrl)) { findings.push({ checkId: "channels.mattermost.input.insecure_callback", severity: "critical", title: "Mattermost interaction callback uses HTTP", detail: `The interaction callbackBaseUrl "${sanitizeForLog(interactions.callbackBaseUrl)}" uses HTTP. This exposes interaction payloads to interception.`, remediation: "Configure interaction.callbackBaseUrl to use HTTPS.", }); } // Check for allowed source IPs if (!interactions.allowedSourceIps || interactions.allowedSourceIps.length === 0) { findings.push({ checkId: "channels.mattermost.input.no_source_ip_restriction", severity: "warn", title: "No IP restrictions on Mattermost interaction callbacks", detail: "Interaction callbacks have no source IP allowlist configured. This may allow spoofed requests from unauthorized sources.", remediation: "Configure interactions.allowedSourceIps to restrict callback sources to your Mattermost server IP/CIDR.", }); } } // ===== 4. FILE DOWNLOAD RESTRICTIONS CHECK ===== // Check 4.1: Private network access if (mmCfg.allowPrivateNetwork === true) { findings.push({ checkId: "channels.mattermost.network.private_access_enabled", severity: "warn", title: "Private network access enabled for Mattermost", detail: "The allowPrivateNetwork setting is enabled, allowing the bot to fetch from private/internal IP addresses. This is required for self-hosted Mattermost but increases attack surface.", remediation: "If not using self-hosted Mattermost on LAN/VPN, set allowPrivateNetwork to false.", }); } // ===== 5. TOKEN PERMISSION CHECKS ===== // Check 5.1: Slash command security const nativeEnabled = resolveNativeCommandsEnabled({ providerId: "mattermost", providerSetting: coerceNativeSetting( (mmCfg.commands as { native?: unknown } | undefined)?.native, ), globalSetting: params.cfg.commands?.native, }); const nativeSkillsEnabled = resolveNativeSkillsEnabled({ providerId: "mattermost", providerSetting: coerceNativeSetting( (mmCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills, ), globalSetting: params.cfg.commands?.nativeSkills, }); const slashCommandEnabled = nativeEnabled || nativeSkillsEnabled || (mmCfg.commands as { enabled?: unknown } | undefined)?.enabled === true; if (slashCommandEnabled) { const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; if (!useAccessGroups) { findings.push({ checkId: "channels.mattermost.commands.access_groups_disabled", severity: "critical", title: "Mattermost slash commands bypass access groups", detail: "Mattermost 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).", }); } // Check for allowlists on slash commands const allowFromRaw = mmCfg.allowFrom; // eslint-disable-next-line no-process-env const storeAllowFrom = await readChannelAllowFromStore("mattermost", process.env, accountId).catch( () => [], ); const ownerAllowFromConfigured = normalizeAllowFromList([...(allowFromRaw || []), ...storeAllowFrom]).length > 0; if (!ownerAllowFromConfigured) { findings.push({ checkId: "channels.mattermost.commands.no_allowlist", severity: "warn", title: "Mattermost slash commands have no allowlist", detail: "Mattermost slash/native commands are enabled, but no owner allowFrom list is configured; commands may be rejected for everyone or accepted from anyone depending on dmPolicy.", remediation: "Configure channels.mattermost.allowFrom with authorized user IDs, or use pairing to approve users.", }); } } // ===== 6. CONFIG MIGRATION DETECTION ===== // Check 6.1: Legacy dmPolicy migration needed const legacyDmPolicy = (mmCfg as { dm?: { policy?: string } }).dm?.policy; if (legacyDmPolicy !== undefined && mmCfg.dmPolicy === undefined) { findings.push({ checkId: "channels.mattermost.config.legacy_dm_policy", severity: "info", title: "Legacy dm.policy configuration detected", detail: "The configuration uses the legacy dm.policy setting which should be migrated to dmPolicy.", remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.", }); } // Check 6.2: Legacy allowFrom migration needed const legacyAllowFrom = (mmCfg as { dm?: { allowFrom?: unknown[] } }).dm?.allowFrom; if (legacyAllowFrom !== undefined && mmCfg.allowFrom === undefined) { findings.push({ checkId: "channels.mattermost.config.legacy_allow_from", severity: "info", title: "Legacy dm.allowFrom configuration detected", detail: "The configuration uses the legacy dm.allowFrom setting which should be migrated to top-level allowFrom.", remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.", }); } // ===== 7. MUTABLE ALLOWLIST WARNINGS ===== // Check 7.1: Mutable entries in allowFrom without dangerous name matching const allowFromList = mmCfg.allowFrom || []; const mutableAllowEntries = normalizeAllowFromList(allowFromList).filter(isMutableAllowEntry); const dangerousNameMatchingEnabled = mmCfg.dangerouslyAllowNameMatching === true; if (mutableAllowEntries.length > 0 && !dangerousNameMatchingEnabled) { const entriesPreview = mutableAllowEntries.slice(0, 3).join(", "); const moreCount = mutableAllowEntries.length > 3 ? ` (+${mutableAllowEntries.length - 3} more)` : ""; findings.push({ checkId: "channels.mattermost.allowlist.mutable_entries", severity: "warn", title: "Mutable allowlist entries detected", detail: `Found ${mutableAllowEntries.length} mutable (non-ID) entries in allowFrom: "${entriesPreview}${moreCount}". These entries match by username/display name which can change, potentially allowing unauthorized access.`, remediation: "Option A: Enable channels.mattermost.dangerouslyAllowNameMatching=true as a break-glass measure. Option B: Resolve names to stable Mattermost IDs and update the allowlist entries.", }); } // ===== 8. ACTION PERMISSIONS CHECK ===== const actions = mmCfg.actions || {}; // Check 8.1: Delete action enabled const deleteEnabled = actions.delete ?? true; if (deleteEnabled !== false) { findings.push({ checkId: "channels.mattermost.actions.delete_enabled", severity: "info", title: "Message delete action is enabled", detail: "The delete action is enabled, allowing the bot to delete messages. This is generally safe but increases the attack surface.", remediation: "If message deletion is not needed, disable with channels.mattermost.actions.delete=false.", }); } // ===== 9. GROUP POLICY SECURITY ===== const groupPolicy = mmCfg.groupPolicy; if (groupPolicy === "open") { findings.push({ checkId: "channels.mattermost.groups.open_policy", severity: "warn", title: "Open group policy configured", detail: "The groupPolicy is set to 'open', allowing any Mattermost user to interact with the bot in group channels. This increases the attack surface.", remediation: "Consider setting groupPolicy to 'allowlist' and configuring groupAllowFrom with authorized users.", }); } // ===== 10. DM POLICY SECURITY ===== const dmPolicy = mmCfg.dmPolicy; if (dmPolicy === "open") { findings.push({ checkId: "channels.mattermost.dm.open_policy", severity: "warn", title: "Open DM policy configured", detail: "The dmPolicy is set to 'open', allowing any Mattermost user to send direct messages to the bot. This increases the attack surface.", remediation: "Consider setting dmPolicy to 'allowlist' or 'pairing' to restrict DM access.", }); } else if (dmPolicy === "pairing") { findings.push({ checkId: "channels.mattermost.dm.pairing_mode", severity: "info", title: "DM pairing mode enabled", detail: "The dmPolicy is set to 'pairing', requiring users to explicitly approve access before sending DMs to the bot. This is the recommended secure configuration.", }); } return findings; } /** * Check if an allowlist entry is mutable (uses name/username instead of stable ID). * Exported for use in doctor.ts */ export function isMattermostMutableAllowEntry(raw: string): boolean { return isMutableAllowEntry(raw); } /** * Collect security audit findings for all enabled Mattermost accounts. */ export async function collectAllMattermostSecurityAuditFindings(params: { cfg: OpenClawConfig; listAccounts: (cfg: OpenClawConfig) => ResolvedMattermostAccount[]; }): Promise { const accounts = params.listAccounts(params.cfg); const allFindings: SecurityAuditFinding[] = []; for (const account of accounts) { if (!account.enabled) continue; const findings = await collectMattermostSecurityAuditFindings({ cfg: params.cfg, accountId: account.accountId, account, }); allFindings.push(...findings); } return allFindings; }