/** * Asynchronous security audit collector functions. * * These functions perform I/O (filesystem, config reads) to detect security issues. */ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js"; import { resolveSandboxConfigForAgent, resolveSandboxToolPolicyForAgent, } from "../agents/sandbox.js"; import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.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 { createConfigIO } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { formatPermissionDetail, formatPermissionRemediation, inspectPathPermissions, safeStat, } from "./audit-fs.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; import type { SkillScanFinding } from "./skill-scanner.js"; import * as skillScanner from "./skill-scanner.js"; import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; title: string; detail: string; remediation?: string; }; type ExecDockerRawFn = ( args: string[], opts?: { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal }, ) => Promise; // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null { if (!p.startsWith("~")) { return p; } const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null; if (!home) { return null; } if (p === "~") { return home; } if (p.startsWith("~/") || p.startsWith("~\\")) { return path.join(home, p.slice(2)); } return null; } async function readPluginManifestExtensions(pluginPath: string): Promise { const manifestPath = path.join(pluginPath, "package.json"); const raw = await fs.readFile(manifestPath, "utf-8").catch(() => ""); if (!raw.trim()) { return []; } const parsed = JSON.parse(raw) as Partial< Record > | null; const extensions = parsed?.[MANIFEST_KEY]?.extensions; if (!Array.isArray(extensions)) { return []; } return extensions.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string { return findings .map((finding) => { const relPath = path.relative(rootDir, finding.file); const filePath = relPath && relPath !== "." && !relPath.startsWith("..") ? relPath : path.basename(finding.file); const normalizedPath = filePath.replaceAll("\\", "/"); return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`; }) .join("\n"); } async function listInstalledPluginDirs(params: { stateDir: string; onReadError?: (error: unknown) => void; }): Promise<{ extensionsDir: string; pluginDirs: string[] }> { const extensionsDir = path.join(params.stateDir, "extensions"); const st = await safeStat(extensionsDir); if (!st.ok || !st.isDir) { return { extensionsDir, pluginDirs: [] }; } const entries = await fs.readdir(extensionsDir, { withFileTypes: true }).catch((err) => { params.onReadError?.(err); return []; }); const pluginDirs = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter(Boolean); return { extensionsDir, pluginDirs }; } function resolveToolPolicies(params: { cfg: OpenClawConfig; agentTools?: AgentToolsConfig; sandboxMode?: "off" | "non-main" | "all"; agentId?: string | null; }): Array { const profile = params.agentTools?.profile ?? params.cfg.tools?.profile; const profilePolicy = resolveToolProfilePolicy(profile); const policies: Array = [ profilePolicy, pickSandboxToolPolicy(params.cfg.tools ?? undefined), pickSandboxToolPolicy(params.agentTools), ]; if (params.sandboxMode === "all") { policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined)); } return policies; } function normalizePluginIdSet(entries: string[]): Set { return new Set(entries.map((entry) => entry.trim().toLowerCase()).filter(Boolean)); } function resolveEnabledExtensionPluginIds(params: { cfg: OpenClawConfig; pluginDirs: string[]; }): string[] { const normalized = normalizePluginsConfig(params.cfg.plugins); if (!normalized.enabled) { return []; } const allowSet = normalizePluginIdSet(normalized.allow); const denySet = normalizePluginIdSet(normalized.deny); const entryById = new Map(); for (const [id, entry] of Object.entries(normalized.entries)) { entryById.set(id.trim().toLowerCase(), entry); } const enabled: string[] = []; for (const id of params.pluginDirs) { const normalizedId = id.trim().toLowerCase(); if (!normalizedId) { continue; } if (denySet.has(normalizedId)) { continue; } if (allowSet.size > 0 && !allowSet.has(normalizedId)) { continue; } if (entryById.get(normalizedId)?.enabled === false) { continue; } enabled.push(normalizedId); } return enabled; } function collectAllowEntries(config?: { allow?: string[]; alsoAllow?: string[] }): string[] { const out: string[] = []; if (Array.isArray(config?.allow)) { out.push(...config.allow); } if (Array.isArray(config?.alsoAllow)) { out.push(...config.alsoAllow); } return out.map((entry) => entry.trim().toLowerCase()).filter(Boolean); } function hasExplicitPluginAllow(params: { allowEntries: string[]; enabledPluginIds: Set; }): boolean { return params.allowEntries.some( (entry) => entry === "group:plugins" || params.enabledPluginIds.has(entry), ); } function hasProviderPluginAllow(params: { byProvider?: Record; enabledPluginIds: Set; }): boolean { if (!params.byProvider) { return false; } for (const policy of Object.values(params.byProvider)) { if ( hasExplicitPluginAllow({ allowEntries: collectAllowEntries(policy), enabledPluginIds: params.enabledPluginIds, }) ) { return true; } } return false; } function isPinnedRegistrySpec(spec: string): boolean { const value = spec.trim(); if (!value) { return false; } const at = value.lastIndexOf("@"); if (at <= 0 || at >= value.length - 1) { return false; } const version = value.slice(at + 1).trim(); return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version); } async function readInstalledPackageVersion(dir: string): Promise { try { const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8"); const parsed = JSON.parse(raw) as { version?: unknown }; return typeof parsed.version === "string" ? parsed.version : undefined; } catch { return undefined; } } // -------------------------------------------------------------------------- // Exported collectors // -------------------------------------------------------------------------- function normalizeDockerLabelValue(raw: string | undefined): string | null { const trimmed = raw?.trim() ?? ""; if (!trimmed || trimmed === "") { return null; } return trimmed; } async function listSandboxBrowserContainers( execDockerRawFn: ExecDockerRawFn, ): Promise { try { const result = await execDockerRawFn( ["ps", "-a", "--filter", "label=openclaw.sandboxBrowser=1", "--format", "{{.Names}}"], { allowFailure: true }, ); if (result.code !== 0) { return null; } return result.stdout .toString("utf8") .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); } catch { return null; } } async function readSandboxBrowserHashLabels(params: { containerName: string; execDockerRawFn: ExecDockerRawFn; }): Promise<{ configHash: string | null; epoch: string | null } | null> { try { const result = await params.execDockerRawFn( [ "inspect", "-f", '{{ index .Config.Labels "openclaw.configHash" }}\t{{ index .Config.Labels "openclaw.browserConfigEpoch" }}', params.containerName, ], { allowFailure: true }, ); if (result.code !== 0) { return null; } const [hashRaw, epochRaw] = result.stdout.toString("utf8").split("\t"); return { configHash: normalizeDockerLabelValue(hashRaw), epoch: normalizeDockerLabelValue(epochRaw), }; } catch { return null; } } function parsePublishedHostFromDockerPortLine(line: string): string | null { const trimmed = line.trim(); const rhs = trimmed.includes("->") ? (trimmed.split("->").at(-1)?.trim() ?? "") : trimmed; if (!rhs) { return null; } const bracketHost = rhs.match(/^\[([^\]]+)\]:\d+$/); if (bracketHost?.[1]) { return bracketHost[1]; } const hostPort = rhs.match(/^([^:]+):\d+$/); if (hostPort?.[1]) { return hostPort[1]; } return null; } function isLoopbackPublishHost(host: string): boolean { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; } async function readSandboxBrowserPortMappings(params: { containerName: string; execDockerRawFn: ExecDockerRawFn; }): Promise { try { const result = await params.execDockerRawFn(["port", params.containerName], { allowFailure: true, }); if (result.code !== 0) { return null; } return result.stdout .toString("utf8") .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); } catch { return null; } } export async function collectSandboxBrowserHashLabelFindings(params?: { execDockerRawFn?: ExecDockerRawFn; }): Promise { const findings: SecurityAuditFinding[] = []; const execFn = params?.execDockerRawFn ?? execDockerRaw; const containers = await listSandboxBrowserContainers(execFn); if (!containers || containers.length === 0) { return findings; } const missingHash: string[] = []; const staleEpoch: string[] = []; const nonLoopbackPublished: string[] = []; for (const containerName of containers) { const labels = await readSandboxBrowserHashLabels({ containerName, execDockerRawFn: execFn }); if (!labels) { continue; } if (!labels.configHash) { missingHash.push(containerName); } if (labels.epoch !== SANDBOX_BROWSER_SECURITY_HASH_EPOCH) { staleEpoch.push(containerName); } const portMappings = await readSandboxBrowserPortMappings({ containerName, execDockerRawFn: execFn, }); if (!portMappings?.length) { continue; } const exposedMappings = portMappings.filter((line) => { const host = parsePublishedHostFromDockerPortLine(line); return Boolean(host && !isLoopbackPublishHost(host)); }); if (exposedMappings.length > 0) { nonLoopbackPublished.push(`${containerName} (${exposedMappings.join("; ")})`); } } if (missingHash.length > 0) { findings.push({ checkId: "sandbox.browser_container.hash_label_missing", severity: "warn", title: "Sandbox browser container missing config hash label", detail: `Containers: ${missingHash.join(", ")}. ` + "These browser containers predate hash-based drift checks and may miss security remediations until recreated.", remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`, }); } if (staleEpoch.length > 0) { findings.push({ checkId: "sandbox.browser_container.hash_epoch_stale", severity: "warn", title: "Sandbox browser container hash epoch is stale", detail: `Containers: ${staleEpoch.join(", ")}. ` + `Expected openclaw.browserConfigEpoch=${SANDBOX_BROWSER_SECURITY_HASH_EPOCH}.`, remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt).`, }); } if (nonLoopbackPublished.length > 0) { findings.push({ checkId: "sandbox.browser_container.non_loopback_publish", severity: "critical", title: "Sandbox browser container publishes ports on non-loopback interfaces", detail: `Containers: ${nonLoopbackPublished.join(", ")}. ` + "Sandbox browser observer/control ports should stay loopback-only to avoid unintended remote access.", remediation: `${formatCliCommand("openclaw sandbox recreate --browser --all")} (add --force to skip prompt), ` + "then verify published ports are bound to 127.0.0.1.", }); } return findings; } export async function collectPluginsTrustFindings(params: { cfg: OpenClawConfig; stateDir: string; }): Promise { const findings: SecurityAuditFinding[] = []; const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({ stateDir: params.stateDir, }); if (pluginDirs.length > 0) { 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 hasAccountStringKey = (account: unknown, key: string) => Boolean( account && typeof account === "object" && hasString((account as Record)[key]), ); const discordConfigured = hasString(params.cfg.channels?.discord?.token) || Boolean( params.cfg.channels?.discord?.accounts && Object.values(params.cfg.channels.discord.accounts).some((a) => hasAccountStringKey(a, "token"), ), ) || hasString(process.env.DISCORD_BOT_TOKEN); const telegramConfigured = hasString(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) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"), ), ) || hasString(process.env.TELEGRAM_BOT_TOKEN); const slackConfigured = hasString(params.cfg.channels?.slack?.botToken) || hasString(params.cfg.channels?.slack?.appToken) || Boolean( params.cfg.channels?.slack?.accounts && Object.values(params.cfg.channels.slack.accounts).some( (a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(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, })); findings.push({ checkId: "plugins.extensions_no_allowlist", severity: skillCommandsLikelyExposed ? "critical" : "warn", title: "Extensions exist but plugins.allow is not set", detail: `Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` + (skillCommandsLikelyExposed ? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk." : ""), remediation: "Set plugins.allow to an explicit list of plugin ids you trust.", }); } const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({ cfg: params.cfg, pluginDirs, }); if (enabledExtensionPluginIds.length > 0) { const enabledPluginSet = new Set(enabledExtensionPluginIds); const contexts: Array<{ label: string; agentId?: string; tools?: AgentToolsConfig; }> = [{ label: "default" }]; for (const entry of params.cfg.agents?.list ?? []) { if (!entry || typeof entry !== "object" || typeof entry.id !== "string") { continue; } contexts.push({ label: `agents.list.${entry.id}`, agentId: entry.id, tools: entry.tools, }); } const permissiveContexts: string[] = []; for (const context of contexts) { const profile = context.tools?.profile ?? params.cfg.tools?.profile; const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile)); const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; const policies = resolveToolPolicies({ cfg: params.cfg, agentTools: context.tools, sandboxMode, agentId: context.agentId, }); const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies); const explicitPluginAllow = !restrictiveProfile && (hasExplicitPluginAllow({ allowEntries: collectAllowEntries(params.cfg.tools), enabledPluginIds: enabledPluginSet, }) || hasProviderPluginAllow({ byProvider: params.cfg.tools?.byProvider, enabledPluginIds: enabledPluginSet, }) || hasExplicitPluginAllow({ allowEntries: collectAllowEntries(context.tools), enabledPluginIds: enabledPluginSet, }) || hasProviderPluginAllow({ byProvider: context.tools?.byProvider, enabledPluginIds: enabledPluginSet, })); if (broadPolicy || explicitPluginAllow) { permissiveContexts.push(context.label); } } if (permissiveContexts.length > 0) { findings.push({ checkId: "plugins.tools_reachable_permissive_policy", severity: "warn", title: "Extension plugin tools may be reachable under permissive tool policy", detail: `Enabled extension plugins: ${enabledExtensionPluginIds.join(", ")}.\n` + `Permissive tool policy contexts:\n${permissiveContexts.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.", }); } } } const pluginInstalls = params.cfg.plugins?.installs ?? {}; const npmPluginInstalls = Object.entries(pluginInstalls).filter( ([, record]) => record?.source === "npm", ); if (npmPluginInstalls.length > 0) { const unpinned = npmPluginInstalls .filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec)) .map(([pluginId, record]) => `${pluginId} (${record.spec})`); if (unpinned.length > 0) { findings.push({ checkId: "plugins.installs_unpinned_npm_specs", severity: "warn", title: "Plugin installs include unpinned npm specs", detail: `Unpinned plugin install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.", }); } const missingIntegrity = npmPluginInstalls .filter( ([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "", ) .map(([pluginId]) => pluginId); if (missingIntegrity.length > 0) { findings.push({ checkId: "plugins.installs_missing_integrity", severity: "warn", title: "Plugin installs are missing integrity metadata", detail: `Plugin install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Reinstall or update plugins to refresh install metadata with resolved integrity hashes.", }); } const pluginVersionDrift: string[] = []; for (const [pluginId, record] of npmPluginInstalls) { const recordedVersion = record.resolvedVersion ?? record.version; if (!recordedVersion) { continue; } const installPath = record.installPath ?? path.join(params.stateDir, "extensions", pluginId); // eslint-disable-next-line no-await-in-loop const installedVersion = await readInstalledPackageVersion(installPath); if (!installedVersion || installedVersion === recordedVersion) { continue; } pluginVersionDrift.push( `${pluginId} (recorded ${recordedVersion}, installed ${installedVersion})`, ); } if (pluginVersionDrift.length > 0) { findings.push({ checkId: "plugins.installs_version_drift", severity: "warn", title: "Plugin install records drift from installed package versions", detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.", }); } } const hookInstalls = params.cfg.hooks?.internal?.installs ?? {}; const npmHookInstalls = Object.entries(hookInstalls).filter( ([, record]) => record?.source === "npm", ); if (npmHookInstalls.length > 0) { const unpinned = npmHookInstalls .filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec)) .map(([hookId, record]) => `${hookId} (${record.spec})`); if (unpinned.length > 0) { findings.push({ checkId: "hooks.installs_unpinned_npm_specs", severity: "warn", title: "Hook installs include unpinned npm specs", detail: `Unpinned hook install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Pin hook install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.", }); } const missingIntegrity = npmHookInstalls .filter( ([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "", ) .map(([hookId]) => hookId); if (missingIntegrity.length > 0) { findings.push({ checkId: "hooks.installs_missing_integrity", severity: "warn", title: "Hook installs are missing integrity metadata", detail: `Hook install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Reinstall or update hooks to refresh install metadata with resolved integrity hashes.", }); } const hookVersionDrift: string[] = []; for (const [hookId, record] of npmHookInstalls) { const recordedVersion = record.resolvedVersion ?? record.version; if (!recordedVersion) { continue; } const installPath = record.installPath ?? path.join(params.stateDir, "hooks", hookId); // eslint-disable-next-line no-await-in-loop const installedVersion = await readInstalledPackageVersion(installPath); if (!installedVersion || installedVersion === recordedVersion) { continue; } hookVersionDrift.push( `${hookId} (recorded ${recordedVersion}, installed ${installedVersion})`, ); } if (hookVersionDrift.length > 0) { findings.push({ checkId: "hooks.installs_version_drift", severity: "warn", title: "Hook install records drift from installed package versions", detail: `Detected hook install metadata drift:\n${hookVersionDrift.map((entry) => `- ${entry}`).join("\n")}`, remediation: "Run `openclaw hooks update --all` (or reinstall affected hooks) to refresh install metadata.", }); } } return findings; } export async function collectIncludeFilePermFindings(params: { configSnapshot: ConfigFileSnapshot; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; if (!params.configSnapshot.exists) { return findings; } const configPath = params.configSnapshot.path; const includePaths = await collectIncludePathsRecursive({ configPath, parsed: params.configSnapshot.parsed, }); if (includePaths.length === 0) { return findings; } for (const p of includePaths) { // eslint-disable-next-line no-await-in-loop const perms = await inspectPathPermissions(p, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (!perms.ok) { continue; } if (perms.worldWritable || perms.groupWritable) { findings.push({ checkId: "fs.config_include.perms_writable", severity: "critical", title: "Config include file is writable by others", detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 0o600, env: params.env, }), }); } else if (perms.worldReadable) { findings.push({ checkId: "fs.config_include.perms_world_readable", severity: "critical", title: "Config include file is world-readable", detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 0o600, env: params.env, }), }); } else if (perms.groupReadable) { findings.push({ checkId: "fs.config_include.perms_group_readable", severity: "warn", title: "Config include file is group-readable", detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, remediation: formatPermissionRemediation({ targetPath: p, perms, isDir: false, posixMode: 0o600, env: params.env, }), }); } } return findings; } export async function collectStateDeepFilesystemFindings(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; stateDir: string; platform?: NodeJS.Platform; execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; const oauthDir = resolveOAuthDir(params.env, params.stateDir); const oauthPerms = await inspectPathPermissions(oauthDir, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (oauthPerms.ok && oauthPerms.isDir) { if (oauthPerms.worldWritable || oauthPerms.groupWritable) { findings.push({ checkId: "fs.credentials_dir.perms_writable", severity: "critical", title: "Credentials dir is writable by others", detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`, remediation: formatPermissionRemediation({ targetPath: oauthDir, perms: oauthPerms, isDir: true, posixMode: 0o700, env: params.env, }), }); } else if (oauthPerms.groupReadable || oauthPerms.worldReadable) { findings.push({ checkId: "fs.credentials_dir.perms_readable", severity: "warn", title: "Credentials dir is readable by others", detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`, remediation: formatPermissionRemediation({ targetPath: oauthDir, perms: oauthPerms, isDir: true, posixMode: 0o700, env: params.env, }), }); } } const agentIds = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents?.list .map((a) => (a && typeof a === "object" && typeof a.id === "string" ? a.id.trim() : "")) .filter(Boolean) : []; const defaultAgentId = resolveDefaultAgentId(params.cfg); const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id)); for (const agentId of ids) { const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); const authPath = path.join(agentDir, "auth-profiles.json"); // eslint-disable-next-line no-await-in-loop const authPerms = await inspectPathPermissions(authPath, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (authPerms.ok) { if (authPerms.worldWritable || authPerms.groupWritable) { findings.push({ checkId: "fs.auth_profiles.perms_writable", severity: "critical", title: "auth-profiles.json is writable by others", detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`, remediation: formatPermissionRemediation({ targetPath: authPath, perms: authPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } else if (authPerms.worldReadable || authPerms.groupReadable) { findings.push({ checkId: "fs.auth_profiles.perms_readable", severity: "warn", title: "auth-profiles.json is readable by others", detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`, remediation: formatPermissionRemediation({ targetPath: authPath, perms: authPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } } const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); // eslint-disable-next-line no-await-in-loop const storePerms = await inspectPathPermissions(storePath, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (storePerms.ok) { if (storePerms.worldReadable || storePerms.groupReadable) { findings.push({ checkId: "fs.sessions_store.perms_readable", severity: "warn", title: "sessions.json is readable by others", detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`, remediation: formatPermissionRemediation({ targetPath: storePath, perms: storePerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } } } const logFile = typeof params.cfg.logging?.file === "string" ? params.cfg.logging.file.trim() : ""; if (logFile) { const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile; if (expanded) { const logPath = path.resolve(expanded); const logPerms = await inspectPathPermissions(logPath, { env: params.env, platform: params.platform, exec: params.execIcacls, }); if (logPerms.ok) { if (logPerms.worldReadable || logPerms.groupReadable) { findings.push({ checkId: "fs.log_file.perms_readable", severity: "warn", title: "Log file is readable by others", detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`, remediation: formatPermissionRemediation({ targetPath: logPath, perms: logPerms, isDir: false, posixMode: 0o600, env: params.env, }), }); } } } } return findings; } export async function readConfigSnapshotForAudit(params: { env: NodeJS.ProcessEnv; configPath: string; }): Promise { return await createConfigIO({ env: params.env, configPath: params.configPath, }).readConfigFileSnapshot(); } export async function collectPluginsCodeSafetyFindings(params: { stateDir: string; }): Promise { const findings: SecurityAuditFinding[] = []; const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({ stateDir: params.stateDir, onReadError: (err) => { findings.push({ checkId: "plugins.code_safety.scan_failed", severity: "warn", title: "Plugin extensions directory scan failed", detail: `Static code scan could not list extensions directory: ${String(err)}`, remediation: "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", }); }, }); for (const pluginName of pluginDirs) { const pluginPath = path.join(extensionsDir, pluginName); const extensionEntries = await readPluginManifestExtensions(pluginPath).catch(() => []); const forcedScanEntries: string[] = []; const escapedEntries: string[] = []; for (const entry of extensionEntries) { const resolvedEntry = path.resolve(pluginPath, entry); if (!isPathInside(pluginPath, resolvedEntry)) { escapedEntries.push(entry); continue; } if (extensionUsesSkippedScannerPath(entry)) { findings.push({ checkId: "plugins.code_safety.entry_path", severity: "warn", title: `Plugin "${pluginName}" entry path is hidden or node_modules`, detail: `Extension entry "${entry}" points to a hidden or node_modules path. Deep code scan will cover this entry explicitly, but review this path choice carefully.`, remediation: "Prefer extension entrypoints under normal source paths like dist/ or src/.", }); } forcedScanEntries.push(resolvedEntry); } if (escapedEntries.length > 0) { findings.push({ checkId: "plugins.code_safety.entry_escape", severity: "critical", title: `Plugin "${pluginName}" has extension entry path traversal`, detail: `Found extension entries that escape the plugin directory:\n${escapedEntries.map((entry) => ` - ${entry}`).join("\n")}`, remediation: "Update the plugin manifest so all openclaw.extensions entries stay inside the plugin directory.", }); } const summary = await skillScanner .scanDirectoryWithSummary(pluginPath, { includeFiles: forcedScanEntries, }) .catch((err) => { findings.push({ checkId: "plugins.code_safety.scan_failed", severity: "warn", title: `Plugin "${pluginName}" code scan failed`, detail: `Static code scan could not complete: ${String(err)}`, remediation: "Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.", }); return null; }); if (!summary) { continue; } if (summary.critical > 0) { const criticalFindings = summary.findings.filter((f) => f.severity === "critical"); const details = formatCodeSafetyDetails(criticalFindings, pluginPath); findings.push({ checkId: "plugins.code_safety", severity: "critical", title: `Plugin "${pluginName}" contains dangerous code patterns`, detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, remediation: "Review the plugin source code carefully before use. If untrusted, remove the plugin from your OpenClaw extensions state directory.", }); } else if (summary.warn > 0) { const warnFindings = summary.findings.filter((f) => f.severity === "warn"); const details = formatCodeSafetyDetails(warnFindings, pluginPath); findings.push({ checkId: "plugins.code_safety", severity: "warn", title: `Plugin "${pluginName}" contains suspicious code patterns`, detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s):\n${details}`, remediation: `Review the flagged code to ensure it is intentional and safe.`, }); } } return findings; } export async function collectInstalledSkillsCodeSafetyFindings(params: { cfg: OpenClawConfig; stateDir: string; }): Promise { const findings: SecurityAuditFinding[] = []; const pluginExtensionsDir = path.join(params.stateDir, "extensions"); const scannedSkillDirs = new Set(); const workspaceDirs = listAgentWorkspaceDirs(params.cfg); for (const workspaceDir of workspaceDirs) { const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg }); for (const entry of entries) { if (entry.skill.source === "openclaw-bundled") { continue; } const skillDir = path.resolve(entry.skill.baseDir); if (isPathInside(pluginExtensionsDir, skillDir)) { // Plugin code is already covered by plugins.code_safety checks. continue; } if (scannedSkillDirs.has(skillDir)) { continue; } scannedSkillDirs.add(skillDir); const skillName = entry.skill.name; const summary = await skillScanner.scanDirectoryWithSummary(skillDir).catch((err) => { findings.push({ checkId: "skills.code_safety.scan_failed", severity: "warn", title: `Skill "${skillName}" code scan failed`, detail: `Static code scan could not complete for ${skillDir}: ${String(err)}`, remediation: "Check file permissions and skill layout, then rerun `openclaw security audit --deep`.", }); return null; }); if (!summary) { continue; } if (summary.critical > 0) { const criticalFindings = summary.findings.filter( (finding) => finding.severity === "critical", ); const details = formatCodeSafetyDetails(criticalFindings, skillDir); findings.push({ checkId: "skills.code_safety", severity: "critical", title: `Skill "${skillName}" contains dangerous code patterns`, detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, remediation: `Review the skill source code before use. If untrusted, remove "${skillDir}".`, }); } else if (summary.warn > 0) { const warnFindings = summary.findings.filter((finding) => finding.severity === "warn"); const details = formatCodeSafetyDetails(warnFindings, skillDir); findings.push({ checkId: "skills.code_safety", severity: "warn", title: `Skill "${skillName}" contains suspicious code patterns`, detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`, remediation: "Review flagged lines to ensure the behavior is intentional and safe.", }); } } } return findings; }