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