From dbfc3d710404790f3a222a36607734a696096f01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 18:25:22 +0100 Subject: [PATCH] perf(test): split plugin trust audit seam --- src/security/audit-extra.async.ts | 502 +------------------ src/security/audit-plugins-phantom.test.ts | 79 --- src/security/audit-plugins-trust.test.ts | 62 ++- src/security/audit-plugins-trust.ts | 546 +++++++++++++++++++++ src/security/audit.nondeep.runtime.ts | 2 +- 5 files changed, 609 insertions(+), 582 deletions(-) delete mode 100644 src/security/audit-plugins-phantom.test.ts create mode 100644 src/security/audit-plugins-trust.ts diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index 2aa97584d5f..16f453d1b06 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -6,26 +6,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; import { SANDBOX_BROWSER_SECURITY_HASH_EPOCH } from "../agents/sandbox/constants.js"; import { execDockerRaw, type ExecDockerRawResult } from "../agents/sandbox/docker.js"; -import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; -import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { resolveSkillSource } from "../agents/skills/source.js"; -import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js"; -import { resolveToolProfilePolicy } from "../agents/tool-policy.js"; import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js"; -import { listChannelPlugins } from "../channels/plugins/index.js"; -import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { formatCliCommand } from "../cli/command-format.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; -import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveOAuthDir } from "../config/paths.js"; -import type { AgentToolsConfig } from "../config/types.tools.js"; -import { readInstalledPackageVersion } from "../infra/package-update-utils.js"; -import { normalizePluginId, normalizePluginsConfig } from "../plugins/config-state.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalLowercaseString, @@ -37,12 +26,13 @@ import { 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 { collectPluginsTrustFindings } from "./audit-plugins-trust.js"; + export type SecurityAuditFinding = { checkId: string; severity: "info" | "warn" | "critical"; @@ -174,85 +164,6 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): .join("\n"); } -function readChannelCommandSetting( - cfg: OpenClawConfig, - channelId: string, - key: "native" | "nativeSkills", -): unknown { - const channelCfg = cfg.channels?.[channelId as keyof NonNullable]; - if (!channelCfg || typeof channelCfg !== "object" || Array.isArray(channelCfg)) { - return undefined; - } - const commands = (channelCfg as { commands?: unknown }).commands; - if (!commands || typeof commands !== "object" || Array.isArray(commands)) { - return undefined; - } - return (commands as Record)[key]; -} - -async function isChannelPluginConfigured( - cfg: OpenClawConfig, - plugin: ReturnType[number], -): Promise { - const accountIds = plugin.config.listAccountIds(cfg); - const candidates = accountIds.length > 0 ? accountIds : [undefined]; - for (const accountId of candidates) { - const inspected = - plugin.config.inspectAccount?.(cfg, accountId) ?? - (await inspectReadOnlyChannelAccount({ - channelId: plugin.id, - cfg, - accountId, - })); - const inspectedRecord = - inspected && typeof inspected === "object" && !Array.isArray(inspected) - ? (inspected as Record) - : null; - let resolvedAccount: unknown = inspected; - if (!resolvedAccount) { - try { - resolvedAccount = plugin.config.resolveAccount(cfg, accountId); - } catch { - resolvedAccount = null; - } - } - let enabled = - typeof inspectedRecord?.enabled === "boolean" - ? inspectedRecord.enabled - : resolvedAccount != null; - if ( - typeof inspectedRecord?.enabled !== "boolean" && - resolvedAccount != null && - plugin.config.isEnabled - ) { - try { - enabled = plugin.config.isEnabled(resolvedAccount, cfg); - } catch { - enabled = false; - } - } - let configured = - typeof inspectedRecord?.configured === "boolean" - ? inspectedRecord.configured - : resolvedAccount != null; - if ( - typeof inspectedRecord?.configured !== "boolean" && - resolvedAccount != null && - plugin.config.isConfigured - ) { - try { - configured = await plugin.config.isConfigured(resolvedAccount, cfg); - } catch { - configured = false; - } - } - if (enabled && configured) { - return true; - } - } - return false; -} - async function listInstalledPluginDirs(params: { stateDir: string; onReadError?: (error: unknown) => void; @@ -273,128 +184,6 @@ async function listInstalledPluginDirs(params: { 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) => normalizeOptionalLowercaseString(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); -} - -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)) { - const normalizedId = normalizeOptionalLowercaseString(id); - if (!normalizedId) { - continue; - } - entryById.set(normalizedId, entry); - } - - const enabled: string[] = []; - for (const id of params.pluginDirs) { - const normalizedId = normalizeOptionalLowercaseString(id); - 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) => normalizeOptionalLowercaseString(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - -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); -} - function buildCodeSafetySummaryCacheKey(params: { dirPath: string; includeFiles?: string[]; @@ -677,293 +466,6 @@ export async function collectSandboxBrowserHashLabelFindings(params?: { 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) { - // Warn about allowlist entries that don't match any installed plugin ID. - // An attacker could register a plugin with an allowlisted ID after the - // allowlist was created, exploiting the pre-approved entry. - // Exclude bundled channel plugin IDs (telegram, discord, etc.) from the - // phantom check — they are never in the extensions directory but are - // legitimate allowlist targets. - const installedPluginIds = new Set(pluginDirs.map((dir) => path.basename(dir).toLowerCase())); - const bundledPluginIds = new Set(listChannelPlugins().map((p) => p.id.toLowerCase())); - const phantomEntries = allow.filter((entry) => { - if (typeof entry !== "string" || entry === "group:plugins") { - return false; - } - const lower = entry.toLowerCase(); - if (installedPluginIds.has(lower) || bundledPluginIds.has(lower)) { - return false; - } - // Also resolve via plugin alias / legacy-ID normalization so that entries - // like a provider ID or a renamed bundled plugin don't produce false-positive - // phantom warnings. normalizePluginId maps aliases to their canonical ID. - const canonicalId = normalizeOptionalLowercaseString(normalizePluginId(entry)) ?? ""; - return !canonicalId || !bundledPluginIds.has(canonicalId); - }); - if (phantomEntries.length > 0) { - findings.push({ - checkId: "plugins.allow_phantom_entries", - severity: "warn", - title: "plugins.allow contains entries with no matching installed plugin", - detail: - `The following plugins.allow entries do not correspond to any installed plugin: ${phantomEntries.join(", ")}.\n` + - "Phantom entries could be exploited by registering a new plugin with an allowlisted ID.", - remediation: - "Remove unused entries from plugins.allow, or verify the expected plugins are installed.", - }); - } - } - - if (!allowConfigured) { - const skillCommandsLikelyExposed = ( - await Promise.all( - listChannelPlugins().map(async (plugin) => { - if ( - plugin.capabilities.nativeCommands !== true && - plugin.commands?.nativeSkillsAutoEnabled !== true - ) { - return false; - } - if (!(await isChannelPluginConfigured(params.cfg, plugin))) { - return false; - } - return resolveNativeSkillsEnabled({ - providerId: plugin.id, - providerSetting: readChannelCommandSetting(params.cfg, plugin.id, "nativeSkills") as - | "auto" - | boolean - | undefined, - globalSetting: params.cfg.commands?.nativeSkills, - }); - }), - ) - ).some(Boolean); - - findings.push({ - checkId: "plugins.extensions_no_allowlist", - 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); - 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); - 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 collectWorkspaceSkillSymlinkEscapeFindings(params: { cfg: OpenClawConfig; skillScanLimits?: WorkspaceSkillScanLimits; diff --git a/src/security/audit-plugins-phantom.test.ts b/src/security/audit-plugins-phantom.test.ts deleted file mode 100644 index 7aada56faa1..00000000000 --- a/src/security/audit-plugins-phantom.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { collectPluginsTrustFindings } from "./audit-extra.async.js"; - -/** - * Mock listChannelPlugins to return a controlled set of bundled plugin IDs. - * This lets the tests verify that bundled IDs are excluded from phantom-entry - * detection without depending on the actual set of shipped channel plugins. - */ -vi.mock("../channels/plugins/index.js", () => ({ - listChannelPlugins: () => [{ id: "bundled-channel-plugin" }], - // Stubs for other named exports used transitively (keep calls safe to invoke). - getChannelPlugin: () => undefined, - getLoadedChannelPlugin: () => undefined, - normalizeChannelId: () => null, -})); - -describe("security audit phantom allowlist detection", () => { - let fixtureRoot = ""; - let caseId = 0; - - const makeTmpDir = async (label: string) => { - const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`); - await fs.mkdir(dir, { recursive: true }); - return dir; - }; - - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-phantom-")); - }); - - afterAll(async () => { - if (fixtureRoot) { - await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined); - } - }); - - it("excludes bundled channel plugin IDs from phantom allowlist warnings", async () => { - const stateDir = await makeTmpDir("phantom-bundled-excluded"); - // Create an extensions directory with one installed plugin so the phantom - // check code path is reached (it only runs when pluginDirs.length > 0). - await fs.mkdir(path.join(stateDir, "extensions", "some-installed-plugin"), { - recursive: true, - }); - - const cfg: OpenClawConfig = { - // Allowlist contains a bundled channel ID and an actually-installed plugin ID. - // Neither should appear as a phantom entry. - plugins: { allow: ["bundled-channel-plugin", "some-installed-plugin"] }, - }; - - const findings = await collectPluginsTrustFindings({ cfg, stateDir }); - const phantomFinding = findings.find((f) => f.checkId === "plugins.allow_phantom_entries"); - expect(phantomFinding).toBeUndefined(); - }); - - it("reports phantom entries for allowlisted IDs that are neither installed nor bundled", async () => { - const stateDir = await makeTmpDir("phantom-reported"); - // Create an extensions directory so the phantom check code path is reached. - await fs.mkdir(path.join(stateDir, "extensions", "installed-plugin"), { recursive: true }); - - const cfg: OpenClawConfig = { - // "ghost-plugin-xyz" is not installed and not a bundled channel plugin. - plugins: { allow: ["installed-plugin", "ghost-plugin-xyz"] }, - }; - - const findings = await collectPluginsTrustFindings({ cfg, stateDir }); - const phantomFinding = findings.find((f) => f.checkId === "plugins.allow_phantom_entries"); - expect(phantomFinding).toBeDefined(); - expect(phantomFinding?.severity).toBe("warn"); - // The phantom finding must identify the ghost entry… - expect(phantomFinding?.detail).toContain("ghost-plugin-xyz"); - // …and must NOT implicate the legitimately installed plugin. - expect(phantomFinding?.detail).not.toContain("installed-plugin"); - }); -}); diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index 7dc4dc043a9..6891bf93049 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -1,10 +1,33 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js"; -import { collectPluginsTrustFindings } from "./audit-extra.async.js"; +import { collectPluginsTrustFindings } from "./audit-plugins-trust.js"; + +const mockChannelPlugins = vi.hoisted(() => [ + { + id: "discord", + capabilities: {}, + commands: {}, + config: { + listAccountIds: () => [], + resolveAccount: () => null, + }, + }, +]); + +vi.mock("../channels/plugins/index.js", () => ({ + getChannelPlugin: (id: string) => mockChannelPlugins.find((plugin) => plugin.id === id), + getLoadedChannelPlugin: () => undefined, + listChannelPlugins: () => mockChannelPlugins, + normalizeChannelId: (id: unknown) => (typeof id === "string" && id ? id : null), +})); + +vi.mock("../channels/read-only-account-inspect.js", () => ({ + inspectReadOnlyChannelAccount: () => null, +})); describe("security audit install metadata findings", () => { let fixtureRoot = ""; @@ -176,6 +199,41 @@ describe("security audit install metadata findings", () => { } } }); + + it("evaluates phantom allowlist findings", async () => { + const bundledStateDir = await makeTmpDir("phantom-bundled-excluded"); + await fs.mkdir(path.join(bundledStateDir, "extensions", "some-installed-plugin"), { + recursive: true, + }); + + const bundledFindings = await runInstallMetadataAudit( + { + plugins: { allow: ["discord", "some-installed-plugin"] }, + }, + bundledStateDir, + ); + expect( + bundledFindings.find((finding) => finding.checkId === "plugins.allow_phantom_entries"), + ).toBeUndefined(); + + const reportedStateDir = await makeTmpDir("phantom-reported"); + await fs.mkdir(path.join(reportedStateDir, "extensions", "installed-plugin"), { + recursive: true, + }); + + const reportedFindings = await runInstallMetadataAudit( + { + plugins: { allow: ["installed-plugin", "ghost-plugin-xyz"] }, + }, + reportedStateDir, + ); + const phantomFinding = reportedFindings.find( + (finding) => finding.checkId === "plugins.allow_phantom_entries", + ); + expect(phantomFinding?.severity).toBe("warn"); + expect(phantomFinding?.detail).toContain("ghost-plugin-xyz"); + expect(phantomFinding?.detail).not.toContain("installed-plugin"); + }); }); describe("security audit extension tool reachability findings", () => { diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts new file mode 100644 index 00000000000..eb5954746e8 --- /dev/null +++ b/src/security/audit-plugins-trust.ts @@ -0,0 +1,546 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { listChannelPlugins } from "../channels/plugins/index.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; +import { resolveNativeSkillsEnabled } from "../config/commands.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentToolsConfig } from "../config/types.tools.js"; +import { readInstalledPackageVersion } from "../infra/package-update-utils.js"; +import { normalizePluginId, normalizePluginsConfig } from "../plugins/config-state.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { safeStat } from "./audit-fs.js"; +import type { SecurityAuditFinding } from "./audit.types.js"; + +type SandboxToolPolicy = import("../agents/sandbox/types.js").SandboxToolPolicy; +type ChannelPlugin = ReturnType[number]; + +type PluginTrustPolicyDeps = { + isToolAllowedByPolicies: typeof import("../agents/tool-policy-match.js").isToolAllowedByPolicies; + pickSandboxToolPolicy: typeof import("./audit-tool-policy.js").pickSandboxToolPolicy; + resolveSandboxConfigForAgent: typeof import("../agents/sandbox/config.js").resolveSandboxConfigForAgent; + resolveSandboxToolPolicyForAgent: typeof import("../agents/sandbox/tool-policy.js").resolveSandboxToolPolicyForAgent; + resolveToolProfilePolicy: typeof import("../agents/tool-policy.js").resolveToolProfilePolicy; +}; + +let pluginTrustPolicyDepsPromise: Promise | undefined; + +async function loadPluginTrustPolicyDeps(): Promise { + pluginTrustPolicyDepsPromise ??= Promise.all([ + import("../agents/sandbox/config.js"), + import("../agents/sandbox/tool-policy.js"), + import("../agents/tool-policy-match.js"), + import("../agents/tool-policy.js"), + import("./audit-tool-policy.js"), + ]).then(([sandboxConfig, sandboxToolPolicy, toolPolicyMatch, toolPolicy, auditToolPolicy]) => ({ + isToolAllowedByPolicies: toolPolicyMatch.isToolAllowedByPolicies, + pickSandboxToolPolicy: auditToolPolicy.pickSandboxToolPolicy, + resolveSandboxConfigForAgent: sandboxConfig.resolveSandboxConfigForAgent, + resolveSandboxToolPolicyForAgent: sandboxToolPolicy.resolveSandboxToolPolicyForAgent, + resolveToolProfilePolicy: toolPolicy.resolveToolProfilePolicy, + })); + return await pluginTrustPolicyDepsPromise; +} + +function readChannelCommandSetting( + cfg: OpenClawConfig, + channelId: string, + key: "native" | "nativeSkills", +): unknown { + const channelCfg = cfg.channels?.[channelId as keyof NonNullable]; + if (!channelCfg || typeof channelCfg !== "object" || Array.isArray(channelCfg)) { + return undefined; + } + const commands = (channelCfg as { commands?: unknown }).commands; + if (!commands || typeof commands !== "object" || Array.isArray(commands)) { + return undefined; + } + return (commands as Record)[key]; +} + +async function isChannelPluginConfigured( + cfg: OpenClawConfig, + plugin: ChannelPlugin, +): Promise { + const accountIds = plugin.config.listAccountIds(cfg); + const candidates = accountIds.length > 0 ? accountIds : [undefined]; + for (const accountId of candidates) { + const inspected = + plugin.config.inspectAccount?.(cfg, accountId) ?? + (await inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + })); + const inspectedRecord = + inspected && typeof inspected === "object" && !Array.isArray(inspected) + ? (inspected as Record) + : null; + let resolvedAccount: unknown = inspected; + if (!resolvedAccount) { + try { + resolvedAccount = plugin.config.resolveAccount(cfg, accountId); + } catch { + resolvedAccount = null; + } + } + let enabled = + typeof inspectedRecord?.enabled === "boolean" + ? inspectedRecord.enabled + : resolvedAccount != null; + if ( + typeof inspectedRecord?.enabled !== "boolean" && + resolvedAccount != null && + plugin.config.isEnabled + ) { + try { + enabled = plugin.config.isEnabled(resolvedAccount, cfg); + } catch { + enabled = false; + } + } + let configured = + typeof inspectedRecord?.configured === "boolean" + ? inspectedRecord.configured + : resolvedAccount != null; + if ( + typeof inspectedRecord?.configured !== "boolean" && + resolvedAccount != null && + plugin.config.isConfigured + ) { + try { + configured = await plugin.config.isConfigured(resolvedAccount, cfg); + } catch { + configured = false; + } + } + if (enabled && configured) { + return true; + } + } + return false; +} + +async function listInstalledPluginDirs(params: { + stateDir: string; + onReadError?: (error: unknown) => void; +}): 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; + deps: PluginTrustPolicyDeps; + agentTools?: AgentToolsConfig; + sandboxMode?: "off" | "non-main" | "all"; + agentId?: string | null; +}): Array { + const profile = params.agentTools?.profile ?? params.cfg.tools?.profile; + const profilePolicy = params.deps.resolveToolProfilePolicy(profile); + const policies: Array = [ + profilePolicy, + params.deps.pickSandboxToolPolicy(params.cfg.tools ?? undefined), + params.deps.pickSandboxToolPolicy(params.agentTools), + ]; + if (params.sandboxMode === "all") { + policies.push( + params.deps.resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined), + ); + } + return policies; +} + +function normalizePluginIdSet(entries: string[]): Set { + return new Set( + entries + .map((entry) => normalizeOptionalLowercaseString(entry)) + .filter((entry): entry is string => Boolean(entry)), + ); +} + +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)) { + const normalizedId = normalizeOptionalLowercaseString(id); + if (!normalizedId) { + continue; + } + entryById.set(normalizedId, entry); + } + + const enabled: string[] = []; + for (const id of params.pluginDirs) { + const normalizedId = normalizeOptionalLowercaseString(id); + 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) => normalizeOptionalLowercaseString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + +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); +} + +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 installedPluginIds = new Set(pluginDirs.map((dir) => path.basename(dir).toLowerCase())); + const bundledPluginIds = new Set(listChannelPlugins().map((p) => p.id.toLowerCase())); + const phantomEntries = allow.filter((entry) => { + if (typeof entry !== "string" || entry === "group:plugins") { + return false; + } + const lower = entry.toLowerCase(); + if (installedPluginIds.has(lower) || bundledPluginIds.has(lower)) { + return false; + } + const canonicalId = normalizeOptionalLowercaseString(normalizePluginId(entry)) ?? ""; + return !canonicalId || !bundledPluginIds.has(canonicalId); + }); + if (phantomEntries.length > 0) { + findings.push({ + checkId: "plugins.allow_phantom_entries", + severity: "warn", + title: "plugins.allow contains entries with no matching installed plugin", + detail: + `The following plugins.allow entries do not correspond to any installed plugin: ${phantomEntries.join(", ")}.\n` + + "Phantom entries could be exploited by registering a new plugin with an allowlisted ID.", + remediation: + "Remove unused entries from plugins.allow, or verify the expected plugins are installed.", + }); + } + } + + if (!allowConfigured) { + const skillCommandsLikelyExposed = ( + await Promise.all( + listChannelPlugins().map(async (plugin) => { + if ( + plugin.capabilities.nativeCommands !== true && + plugin.commands?.nativeSkillsAutoEnabled !== true + ) { + return false; + } + if (!(await isChannelPluginConfigured(params.cfg, plugin))) { + return false; + } + return resolveNativeSkillsEnabled({ + providerId: plugin.id, + providerSetting: readChannelCommandSetting(params.cfg, plugin.id, "nativeSkills") as + | "auto" + | boolean + | undefined, + globalSetting: params.cfg.commands?.nativeSkills, + }); + }), + ) + ).some(Boolean); + + findings.push({ + checkId: "plugins.extensions_no_allowlist", + 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 deps = await loadPluginTrustPolicyDeps(); + 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(deps.resolveToolProfilePolicy(profile)); + const sandboxMode = deps.resolveSandboxConfigForAgent(params.cfg, context.agentId).mode; + const policies = resolveToolPolicies({ + cfg: params.cfg, + deps, + agentTools: context.tools, + sandboxMode, + agentId: context.agentId, + }); + const broadPolicy = deps.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); + 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); + 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; +} diff --git a/src/security/audit.nondeep.runtime.ts b/src/security/audit.nondeep.runtime.ts index eae30e69b61..b6de420d8e6 100644 --- a/src/security/audit.nondeep.runtime.ts +++ b/src/security/audit.nondeep.runtime.ts @@ -22,8 +22,8 @@ export { export { collectSandboxBrowserHashLabelFindings, collectIncludeFilePermFindings, - collectPluginsTrustFindings, collectStateDeepFilesystemFindings, collectWorkspaceSkillSymlinkEscapeFindings, readConfigSnapshotForAudit, } from "./audit-extra.async.js"; +export { collectPluginsTrustFindings } from "./audit-plugins-trust.js";