386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
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<string | number> | 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<SecurityAuditFinding[]> {
|
|
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<SecurityAuditFinding[]> {
|
|
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;
|
|
}
|