mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 01:41:22 +00:00
fix(security): harden npm plugin and hook install integrity flow
This commit is contained in:
@@ -5,23 +5,25 @@
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import type { SkillScanFinding } from "./skill-scanner.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
|
||||
import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxToolPolicyForAgent,
|
||||
} from "../agents/sandbox.js";
|
||||
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import { listAgentWorkspaceDirs } from "../agents/workspace-dirs.js";
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
|
||||
import { createConfigIO } from "../config/config.js";
|
||||
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
|
||||
import { resolveOAuthDir } from "../config/paths.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
@@ -32,9 +34,7 @@ import {
|
||||
} from "./audit-fs.js";
|
||||
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
|
||||
import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js";
|
||||
import type { SkillScanFinding } from "./skill-scanner.js";
|
||||
import * as skillScanner from "./skill-scanner.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
@@ -215,6 +215,29 @@ function hasProviderPluginAllow(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPinnedRegistrySpec(spec: string): boolean {
|
||||
const value = spec.trim();
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const at = value.lastIndexOf("@");
|
||||
if (at <= 0 || at >= value.length - 1) {
|
||||
return false;
|
||||
}
|
||||
const version = value.slice(at + 1).trim();
|
||||
return /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version);
|
||||
}
|
||||
|
||||
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(dir, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Exported collectors
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -227,155 +250,279 @@ export async function collectPluginsTrustFindings(params: {
|
||||
const { extensionsDir, pluginDirs } = await listInstalledPluginDirs({
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
if (pluginDirs.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
if (pluginDirs.length > 0) {
|
||||
const allow = params.cfg.plugins?.allow;
|
||||
const allowConfigured = Array.isArray(allow) && allow.length > 0;
|
||||
if (!allowConfigured) {
|
||||
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
|
||||
const hasAccountStringKey = (account: unknown, key: string) =>
|
||||
Boolean(
|
||||
account &&
|
||||
typeof account === "object" &&
|
||||
hasString((account as Record<string, unknown>)[key]),
|
||||
);
|
||||
|
||||
const allow = params.cfg.plugins?.allow;
|
||||
const allowConfigured = Array.isArray(allow) && allow.length > 0;
|
||||
if (!allowConfigured) {
|
||||
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
|
||||
const hasAccountStringKey = (account: unknown, key: string) =>
|
||||
Boolean(
|
||||
account &&
|
||||
typeof account === "object" &&
|
||||
hasString((account as Record<string, unknown>)[key]),
|
||||
);
|
||||
const discordConfigured =
|
||||
hasString(params.cfg.channels?.discord?.token) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.discord?.accounts &&
|
||||
Object.values(params.cfg.channels.discord.accounts).some((a) =>
|
||||
hasAccountStringKey(a, "token"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
const discordConfigured =
|
||||
hasString(params.cfg.channels?.discord?.token) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.discord?.accounts &&
|
||||
Object.values(params.cfg.channels.discord.accounts).some((a) =>
|
||||
hasAccountStringKey(a, "token"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.DISCORD_BOT_TOKEN);
|
||||
const telegramConfigured =
|
||||
hasString(params.cfg.channels?.telegram?.botToken) ||
|
||||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.telegram?.accounts &&
|
||||
Object.values(params.cfg.channels.telegram.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.TELEGRAM_BOT_TOKEN);
|
||||
|
||||
const telegramConfigured =
|
||||
hasString(params.cfg.channels?.telegram?.botToken) ||
|
||||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.telegram?.accounts &&
|
||||
Object.values(params.cfg.channels.telegram.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.TELEGRAM_BOT_TOKEN);
|
||||
const slackConfigured =
|
||||
hasString(params.cfg.channels?.slack?.botToken) ||
|
||||
hasString(params.cfg.channels?.slack?.appToken) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.slack?.accounts &&
|
||||
Object.values(params.cfg.channels.slack.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.SLACK_BOT_TOKEN) ||
|
||||
hasString(process.env.SLACK_APP_TOKEN);
|
||||
|
||||
const slackConfigured =
|
||||
hasString(params.cfg.channels?.slack?.botToken) ||
|
||||
hasString(params.cfg.channels?.slack?.appToken) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.slack?.accounts &&
|
||||
Object.values(params.cfg.channels.slack.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.SLACK_BOT_TOKEN) ||
|
||||
hasString(process.env.SLACK_APP_TOKEN);
|
||||
|
||||
const skillCommandsLikelyExposed =
|
||||
(discordConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(telegramConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(slackConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
}));
|
||||
|
||||
findings.push({
|
||||
checkId: "plugins.extensions_no_allowlist",
|
||||
severity: skillCommandsLikelyExposed ? "critical" : "warn",
|
||||
title: "Extensions exist but plugins.allow is not set",
|
||||
detail:
|
||||
`Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` +
|
||||
(skillCommandsLikelyExposed
|
||||
? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk."
|
||||
: ""),
|
||||
remediation: "Set plugins.allow to an explicit list of plugin ids you trust.",
|
||||
});
|
||||
}
|
||||
|
||||
const enabledExtensionPluginIds = resolveEnabledExtensionPluginIds({
|
||||
cfg: params.cfg,
|
||||
pluginDirs,
|
||||
});
|
||||
if (enabledExtensionPluginIds.length > 0) {
|
||||
const enabledPluginSet = new Set(enabledExtensionPluginIds);
|
||||
const contexts: Array<{
|
||||
label: string;
|
||||
agentId?: string;
|
||||
tools?: AgentToolsConfig;
|
||||
}> = [{ label: "default" }];
|
||||
for (const entry of params.cfg.agents?.list ?? []) {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
contexts.push({
|
||||
label: `agents.list.${entry.id}`,
|
||||
agentId: entry.id,
|
||||
tools: entry.tools,
|
||||
});
|
||||
}
|
||||
|
||||
const permissiveContexts: string[] = [];
|
||||
for (const context of contexts) {
|
||||
const profile = context.tools?.profile ?? params.cfg.tools?.profile;
|
||||
const restrictiveProfile = Boolean(resolveToolProfilePolicy(profile));
|
||||
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, context.agentId).mode;
|
||||
const policies = resolveToolPolicies({
|
||||
cfg: params.cfg,
|
||||
agentTools: context.tools,
|
||||
sandboxMode,
|
||||
agentId: context.agentId,
|
||||
});
|
||||
const broadPolicy = isToolAllowedByPolicies("__openclaw_plugin_probe__", policies);
|
||||
const explicitPluginAllow =
|
||||
!restrictiveProfile &&
|
||||
(hasExplicitPluginAllow({
|
||||
allowEntries: collectAllowEntries(params.cfg.tools),
|
||||
enabledPluginIds: enabledPluginSet,
|
||||
}) ||
|
||||
hasProviderPluginAllow({
|
||||
byProvider: params.cfg.tools?.byProvider,
|
||||
enabledPluginIds: enabledPluginSet,
|
||||
}) ||
|
||||
hasExplicitPluginAllow({
|
||||
allowEntries: collectAllowEntries(context.tools),
|
||||
enabledPluginIds: enabledPluginSet,
|
||||
}) ||
|
||||
hasProviderPluginAllow({
|
||||
byProvider: context.tools?.byProvider,
|
||||
enabledPluginIds: enabledPluginSet,
|
||||
const skillCommandsLikelyExposed =
|
||||
(discordConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(telegramConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(slackConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
}));
|
||||
|
||||
if (broadPolicy || explicitPluginAllow) {
|
||||
permissiveContexts.push(context.label);
|
||||
}
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissiveContexts.length > 0) {
|
||||
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.tools_reachable_permissive_policy",
|
||||
checkId: "plugins.installs_unpinned_npm_specs",
|
||||
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")}`,
|
||||
title: "Plugin installs include unpinned npm specs",
|
||||
detail: `Unpinned plugin install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Use restrictive profiles (`minimal`/`coding`) or explicit tool allowlists that exclude plugin tools for agents handling untrusted input.",
|
||||
"Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
|
||||
});
|
||||
}
|
||||
|
||||
const missingIntegrity = npmPluginInstalls
|
||||
.filter(
|
||||
([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "",
|
||||
)
|
||||
.map(([pluginId]) => pluginId);
|
||||
if (missingIntegrity.length > 0) {
|
||||
findings.push({
|
||||
checkId: "plugins.installs_missing_integrity",
|
||||
severity: "warn",
|
||||
title: "Plugin installs are missing integrity metadata",
|
||||
detail: `Plugin install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Reinstall or update plugins to refresh install metadata with resolved integrity hashes.",
|
||||
});
|
||||
}
|
||||
|
||||
const pluginVersionDrift: string[] = [];
|
||||
for (const [pluginId, record] of npmPluginInstalls) {
|
||||
const recordedVersion = record.resolvedVersion ?? record.version;
|
||||
if (!recordedVersion) {
|
||||
continue;
|
||||
}
|
||||
const installPath = record.installPath ?? path.join(params.stateDir, "extensions", pluginId);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const installedVersion = await readInstalledPackageVersion(installPath);
|
||||
if (!installedVersion || installedVersion === recordedVersion) {
|
||||
continue;
|
||||
}
|
||||
pluginVersionDrift.push(
|
||||
`${pluginId} (recorded ${recordedVersion}, installed ${installedVersion})`,
|
||||
);
|
||||
}
|
||||
if (pluginVersionDrift.length > 0) {
|
||||
findings.push({
|
||||
checkId: "plugins.installs_version_drift",
|
||||
severity: "warn",
|
||||
title: "Plugin install records drift from installed package versions",
|
||||
detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hookInstalls = params.cfg.hooks?.internal?.installs ?? {};
|
||||
const npmHookInstalls = Object.entries(hookInstalls).filter(
|
||||
([, record]) => record?.source === "npm",
|
||||
);
|
||||
if (npmHookInstalls.length > 0) {
|
||||
const unpinned = npmHookInstalls
|
||||
.filter(([, record]) => typeof record.spec === "string" && !isPinnedRegistrySpec(record.spec))
|
||||
.map(([hookId, record]) => `${hookId} (${record.spec})`);
|
||||
if (unpinned.length > 0) {
|
||||
findings.push({
|
||||
checkId: "hooks.installs_unpinned_npm_specs",
|
||||
severity: "warn",
|
||||
title: "Hook installs include unpinned npm specs",
|
||||
detail: `Unpinned hook install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Pin hook install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
|
||||
});
|
||||
}
|
||||
|
||||
const missingIntegrity = npmHookInstalls
|
||||
.filter(
|
||||
([, record]) => typeof record.integrity !== "string" || record.integrity.trim() === "",
|
||||
)
|
||||
.map(([hookId]) => hookId);
|
||||
if (missingIntegrity.length > 0) {
|
||||
findings.push({
|
||||
checkId: "hooks.installs_missing_integrity",
|
||||
severity: "warn",
|
||||
title: "Hook installs are missing integrity metadata",
|
||||
detail: `Hook install records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Reinstall or update hooks to refresh install metadata with resolved integrity hashes.",
|
||||
});
|
||||
}
|
||||
|
||||
const hookVersionDrift: string[] = [];
|
||||
for (const [hookId, record] of npmHookInstalls) {
|
||||
const recordedVersion = record.resolvedVersion ?? record.version;
|
||||
if (!recordedVersion) {
|
||||
continue;
|
||||
}
|
||||
const installPath = record.installPath ?? path.join(params.stateDir, "hooks", hookId);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const installedVersion = await readInstalledPackageVersion(installPath);
|
||||
if (!installedVersion || installedVersion === recordedVersion) {
|
||||
continue;
|
||||
}
|
||||
hookVersionDrift.push(
|
||||
`${hookId} (recorded ${recordedVersion}, installed ${installedVersion})`,
|
||||
);
|
||||
}
|
||||
if (hookVersionDrift.length > 0) {
|
||||
findings.push({
|
||||
checkId: "hooks.installs_version_drift",
|
||||
severity: "warn",
|
||||
title: "Hook install records drift from installed package versions",
|
||||
detail: `Detected hook install metadata drift:\n${hookVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Run `openclaw hooks update --all` (or reinstall affected hooks) to refresh install metadata.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
|
||||
import { collectPluginsCodeSafetyFindings } from "./audit-extra.js";
|
||||
import { runSecurityAudit } from "./audit.js";
|
||||
import type { SecurityAuditOptions, SecurityAuditReport } from "./audit.js";
|
||||
import * as skillScanner from "./skill-scanner.js";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
@@ -1502,6 +1502,143 @@ describe("security audit", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns on unpinned npm install specs and missing integrity metadata", async () => {
|
||||
const tmp = await makeTmpDir("install-metadata-warns");
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"test-hooks": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/test-hooks",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
});
|
||||
|
||||
expect(hasFinding(res, "plugins.installs_unpinned_npm_specs", "warn")).toBe(true);
|
||||
expect(hasFinding(res, "plugins.installs_missing_integrity", "warn")).toBe(true);
|
||||
expect(hasFinding(res, "hooks.installs_unpinned_npm_specs", "warn")).toBe(true);
|
||||
expect(hasFinding(res, "hooks.installs_missing_integrity", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not warn on pinned npm install specs with integrity metadata", async () => {
|
||||
const tmp = await makeTmpDir("install-metadata-clean");
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"test-hooks": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/test-hooks@1.2.3",
|
||||
integrity: "sha512-hook",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
});
|
||||
|
||||
expect(hasFinding(res, "plugins.installs_unpinned_npm_specs")).toBe(false);
|
||||
expect(hasFinding(res, "plugins.installs_missing_integrity")).toBe(false);
|
||||
expect(hasFinding(res, "hooks.installs_unpinned_npm_specs")).toBe(false);
|
||||
expect(hasFinding(res, "hooks.installs_missing_integrity")).toBe(false);
|
||||
});
|
||||
|
||||
it("warns when install records drift from installed package versions", async () => {
|
||||
const tmp = await makeTmpDir("install-version-drift");
|
||||
const stateDir = path.join(tmp, "state");
|
||||
const pluginDir = path.join(stateDir, "extensions", "voice-call");
|
||||
const hookDir = path.join(stateDir, "hooks", "test-hooks");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.mkdir(hookDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/voice-call", version: "9.9.9" }),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(hookDir, "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/test-hooks", version: "8.8.8" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"test-hooks": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/test-hooks@1.2.3",
|
||||
integrity: "sha512-hook",
|
||||
resolvedVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
});
|
||||
|
||||
expect(hasFinding(res, "plugins.installs_version_drift", "warn")).toBe(true);
|
||||
expect(hasFinding(res, "hooks.installs_version_drift", "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags enabled extensions when tool policy can expose plugin tools", async () => {
|
||||
const tmp = await makeTmpDir("plugins-reachable");
|
||||
const stateDir = path.join(tmp, "state");
|
||||
|
||||
Reference in New Issue
Block a user