mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
perf(test): split plugin trust audit seam
This commit is contained in:
@@ -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<OpenClawConfig["channels"]>];
|
||||
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<string, unknown>)[key];
|
||||
}
|
||||
|
||||
async function isChannelPluginConfigured(
|
||||
cfg: OpenClawConfig,
|
||||
plugin: ReturnType<typeof listChannelPlugins>[number],
|
||||
): Promise<boolean> {
|
||||
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<string, unknown>)
|
||||
: 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<SandboxToolPolicy | undefined> {
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const policies: Array<SandboxToolPolicy | undefined> = [
|
||||
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<string> {
|
||||
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<string, { enabled?: boolean }>();
|
||||
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<string>;
|
||||
}): boolean {
|
||||
return params.allowEntries.some(
|
||||
(entry) => entry === "group:plugins" || params.enabledPluginIds.has(entry),
|
||||
);
|
||||
}
|
||||
|
||||
function hasProviderPluginAllow(params: {
|
||||
byProvider?: Record<string, { allow?: string[]; alsoAllow?: string[]; deny?: string[] }>;
|
||||
enabledPluginIds: Set<string>;
|
||||
}): 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<SecurityAuditFinding[]> {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
546
src/security/audit-plugins-trust.ts
Normal file
546
src/security/audit-plugins-trust.ts
Normal file
@@ -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<typeof listChannelPlugins>[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<PluginTrustPolicyDeps> | undefined;
|
||||
|
||||
async function loadPluginTrustPolicyDeps(): Promise<PluginTrustPolicyDeps> {
|
||||
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<OpenClawConfig["channels"]>];
|
||||
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<string, unknown>)[key];
|
||||
}
|
||||
|
||||
async function isChannelPluginConfigured(
|
||||
cfg: OpenClawConfig,
|
||||
plugin: ChannelPlugin,
|
||||
): Promise<boolean> {
|
||||
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<string, unknown>)
|
||||
: 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<SandboxToolPolicy | undefined> {
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = params.deps.resolveToolProfilePolicy(profile);
|
||||
const policies: Array<SandboxToolPolicy | undefined> = [
|
||||
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<string> {
|
||||
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<string, { enabled?: boolean }>();
|
||||
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<string>;
|
||||
}): boolean {
|
||||
return params.allowEntries.some(
|
||||
(entry) => entry === "group:plugins" || params.enabledPluginIds.has(entry),
|
||||
);
|
||||
}
|
||||
|
||||
function hasProviderPluginAllow(params: {
|
||||
byProvider?: Record<string, { allow?: string[]; alsoAllow?: string[]; deny?: string[] }>;
|
||||
enabledPluginIds: Set<string>;
|
||||
}): 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<SecurityAuditFinding[]> {
|
||||
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;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ export {
|
||||
export {
|
||||
collectSandboxBrowserHashLabelFindings,
|
||||
collectIncludeFilePermFindings,
|
||||
collectPluginsTrustFindings,
|
||||
collectStateDeepFilesystemFindings,
|
||||
collectWorkspaceSkillSymlinkEscapeFindings,
|
||||
readConfigSnapshotForAudit,
|
||||
} from "./audit-extra.async.js";
|
||||
export { collectPluginsTrustFindings } from "./audit-plugins-trust.js";
|
||||
|
||||
Reference in New Issue
Block a user