mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(security): extend audit hardening checks
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
|
||||
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
|
||||
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
|
||||
- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
|
||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
|
||||
@@ -25,3 +25,4 @@ openclaw security audit --fix
|
||||
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
|
||||
@@ -45,6 +45,7 @@ Start with the smallest access that still works, then widen it as you gain confi
|
||||
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
|
||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||
- **Plugins** (extensions exist without an explicit allowlist).
|
||||
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
|
||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
||||
|
||||
If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
|
||||
|
||||
@@ -6,15 +6,24 @@
|
||||
import JSON5 from "json5";
|
||||
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 { ExecFn } from "./windows-acl.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
|
||||
import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxToolPolicyForAgent,
|
||||
} from "../agents/sandbox.js";
|
||||
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import { createConfigIO } from "../config/config.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { resolveOAuthDir } from "../config/paths.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
formatPermissionDetail,
|
||||
@@ -196,6 +205,135 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string):
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function unionAllow(base?: string[], extra?: string[]): string[] | undefined {
|
||||
if (!Array.isArray(extra) || extra.length === 0) {
|
||||
return base;
|
||||
}
|
||||
if (!Array.isArray(base) || base.length === 0) {
|
||||
return Array.from(new Set(["*", ...extra]));
|
||||
}
|
||||
return Array.from(new Set([...base, ...extra]));
|
||||
}
|
||||
|
||||
function pickToolPolicy(config?: {
|
||||
allow?: string[];
|
||||
alsoAllow?: string[];
|
||||
deny?: string[];
|
||||
}): SandboxToolPolicy | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const allow = Array.isArray(config.allow)
|
||||
? unionAllow(config.allow, config.alsoAllow)
|
||||
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
|
||||
? unionAllow(undefined, config.alsoAllow)
|
||||
: undefined;
|
||||
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||
if (!allow && !deny) {
|
||||
return undefined;
|
||||
}
|
||||
return { allow, deny };
|
||||
}
|
||||
|
||||
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,
|
||||
pickToolPolicy(params.cfg.tools ?? undefined),
|
||||
pickToolPolicy(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;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Exported collectors
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -297,6 +435,78 @@ export async function collectPluginsTrustFindings(params: {
|
||||
});
|
||||
}
|
||||
|
||||
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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { resolveNodeCommandAllowlist } from "../gateway/node-command-policy.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
@@ -185,11 +186,29 @@ function extractAgentIdFromSource(source: string): string | null {
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): SandboxToolPolicy | null {
|
||||
function unionAllow(base?: string[], extra?: string[]): string[] | undefined {
|
||||
if (!Array.isArray(extra) || extra.length === 0) {
|
||||
return base;
|
||||
}
|
||||
if (!Array.isArray(base) || base.length === 0) {
|
||||
return Array.from(new Set(["*", ...extra]));
|
||||
}
|
||||
return Array.from(new Set([...base, ...extra]));
|
||||
}
|
||||
|
||||
function pickToolPolicy(config?: {
|
||||
allow?: string[];
|
||||
alsoAllow?: string[];
|
||||
deny?: string[];
|
||||
}): SandboxToolPolicy | null {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const allow = Array.isArray(config.allow) ? config.allow : undefined;
|
||||
const allow = Array.isArray(config.allow)
|
||||
? unionAllow(config.allow, config.alsoAllow)
|
||||
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
|
||||
? unionAllow(undefined, config.alsoAllow)
|
||||
: undefined;
|
||||
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||
if (!allow && !deny) {
|
||||
return null;
|
||||
@@ -197,6 +216,61 @@ function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): Sandbox
|
||||
return { allow, deny };
|
||||
}
|
||||
|
||||
function hasConfiguredDockerConfig(
|
||||
docker: Record<string, unknown> | undefined | null,
|
||||
): docker is Record<string, unknown> {
|
||||
if (!docker || typeof docker !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(docker).some((value) => value !== undefined);
|
||||
}
|
||||
|
||||
function normalizeNodeCommand(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
|
||||
const baseCfg: OpenClawConfig = {
|
||||
...cfg,
|
||||
gateway: {
|
||||
...cfg.gateway,
|
||||
nodes: {
|
||||
...cfg.gateway?.nodes,
|
||||
denyCommands: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const out = new Set<string>();
|
||||
for (const platform of ["ios", "android", "macos", "linux", "windows", "unknown"]) {
|
||||
const allow = resolveNodeCommandAllowlist(baseCfg, { platform });
|
||||
for (const cmd of allow) {
|
||||
const normalized = normalizeNodeCommand(cmd);
|
||||
if (normalized) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function looksLikeNodeCommandPattern(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
if (/[?*[\]{}(),|]/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.endsWith("/") ||
|
||||
value.startsWith("^") ||
|
||||
value.endsWith("$")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /\s/.test(value) || value.includes("group:");
|
||||
}
|
||||
|
||||
function resolveToolPolicies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentTools?: AgentToolsConfig;
|
||||
@@ -471,6 +545,141 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const configuredPaths: string[] = [];
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
|
||||
const defaultsSandbox = cfg.agents?.defaults?.sandbox;
|
||||
const hasDefaultDocker = hasConfiguredDockerConfig(
|
||||
defaultsSandbox?.docker as Record<string, unknown> | undefined,
|
||||
);
|
||||
const defaultMode = defaultsSandbox?.mode ?? "off";
|
||||
const hasAnySandboxEnabledAgent = agents.some((entry) => {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
return resolveSandboxConfigForAgent(cfg, entry.id).mode !== "off";
|
||||
});
|
||||
if (hasDefaultDocker && defaultMode === "off" && !hasAnySandboxEnabledAgent) {
|
||||
configuredPaths.push("agents.defaults.sandbox.docker");
|
||||
}
|
||||
|
||||
for (const entry of agents) {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!hasConfiguredDockerConfig(entry.sandbox?.docker as Record<string, unknown> | undefined)) {
|
||||
continue;
|
||||
}
|
||||
if (resolveSandboxConfigForAgent(cfg, entry.id).mode === "off") {
|
||||
configuredPaths.push(`agents.list.${entry.id}.sandbox.docker`);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuredPaths.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
findings.push({
|
||||
checkId: "sandbox.docker_config_mode_off",
|
||||
severity: "warn",
|
||||
title: "Sandbox docker settings configured while sandbox mode is off",
|
||||
detail:
|
||||
"These docker settings will not take effect until sandbox mode is enabled:\n" +
|
||||
configuredPaths.map((entry) => `- ${entry}`).join("\n"),
|
||||
remediation:
|
||||
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) where needed, or remove unused docker settings.',
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const denyListRaw = cfg.gateway?.nodes?.denyCommands;
|
||||
if (!Array.isArray(denyListRaw) || denyListRaw.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const denyList = denyListRaw.map(normalizeNodeCommand).filter(Boolean);
|
||||
if (denyList.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const knownCommands = listKnownNodeCommands(cfg);
|
||||
const patternLike = denyList.filter((entry) => looksLikeNodeCommandPattern(entry));
|
||||
const unknownExact = denyList.filter(
|
||||
(entry) => !looksLikeNodeCommandPattern(entry) && !knownCommands.has(entry),
|
||||
);
|
||||
if (patternLike.length === 0 && unknownExact.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const detailParts: string[] = [];
|
||||
if (patternLike.length > 0) {
|
||||
detailParts.push(
|
||||
`Pattern-like entries (not supported by exact matching): ${patternLike.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (unknownExact.length > 0) {
|
||||
detailParts.push(
|
||||
`Unknown command names (not in defaults/allowCommands): ${unknownExact.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const examples = Array.from(knownCommands).slice(0, 8);
|
||||
|
||||
findings.push({
|
||||
checkId: "gateway.nodes.deny_commands_ineffective",
|
||||
severity: "warn",
|
||||
title: "Some gateway.nodes.denyCommands entries are ineffective",
|
||||
detail:
|
||||
"gateway.nodes.denyCommands uses exact command-name matching only.\n" +
|
||||
detailParts.map((entry) => `- ${entry}`).join("\n"),
|
||||
remediation:
|
||||
`Use exact command names (for example: ${examples.join(", ")}). ` +
|
||||
"If you need broader restrictions, remove risky commands from allowCommands/default workflows.",
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectMinimalProfileOverrideFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (cfg.tools?.profile !== "minimal") {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const overrides = (cfg.agents?.list ?? [])
|
||||
.filter((entry): entry is { id: string; tools?: AgentToolsConfig } => {
|
||||
return Boolean(
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.id === "string" &&
|
||||
entry.tools?.profile &&
|
||||
entry.tools.profile !== "minimal",
|
||||
);
|
||||
})
|
||||
.map((entry) => `${entry.id}=${entry.tools?.profile}`);
|
||||
|
||||
if (overrides.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
findings.push({
|
||||
checkId: "tools.profile_minimal_overridden",
|
||||
severity: "warn",
|
||||
title: "Global tools.profile=minimal is overridden by agent profiles",
|
||||
detail:
|
||||
"Global minimal profile is set, but these agent profiles take precedence:\n" +
|
||||
overrides.map((entry) => `- agents.list.${entry}`).join("\n"),
|
||||
remediation:
|
||||
'Set those agents to `tools.profile="minimal"` (or remove the agent override) if you want minimal tools enforced globally.',
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(cfg);
|
||||
|
||||
@@ -12,7 +12,10 @@ export {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectExposureMatrixFindings,
|
||||
collectHooksHardeningFindings,
|
||||
collectMinimalProfileOverrideFindings,
|
||||
collectModelHygieneFindings,
|
||||
collectNodeDenyCommandPatternFindings,
|
||||
collectSandboxDockerNoopFindings,
|
||||
collectSecretsInConfigFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
collectSyncedFolderFindings,
|
||||
|
||||
@@ -303,6 +303,110 @@ describe("security audit", () => {
|
||||
expect(finding?.detail).toContain("sandbox=all");
|
||||
});
|
||||
|
||||
it("flags sandbox docker config when sandbox mode is off", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
docker: { image: "ghcr.io/example/sandbox:latest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "sandbox.docker_config_mode_off",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not flag global sandbox docker config when an agent enables sandbox mode", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "off",
|
||||
docker: { image: "ghcr.io/example/sandbox:latest" },
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops", sandbox: { mode: "all" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings.some((f) => f.checkId === "sandbox.docker_config_mode_off")).toBe(false);
|
||||
});
|
||||
|
||||
it("flags ineffective gateway.nodes.denyCommands entries", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
nodes: {
|
||||
denyCommands: ["system.*", "system.runx"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
const finding = res.findings.find(
|
||||
(f) => f.checkId === "gateway.nodes.deny_commands_ineffective",
|
||||
);
|
||||
expect(finding?.severity).toBe("warn");
|
||||
expect(finding?.detail).toContain("system.*");
|
||||
expect(finding?.detail).toContain("system.runx");
|
||||
});
|
||||
|
||||
it("flags agent profile overrides when global tools.profile is minimal", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
profile: "minimal",
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "owner",
|
||||
tools: { profile: "full" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "tools.profile_minimal_overridden",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags tools.elevated allowFrom wildcard as critical", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
@@ -1149,6 +1253,68 @@ describe("security audit", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("flags enabled extensions when tool policy can expose plugin tools", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-"));
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: { allow: ["some-plugin"] },
|
||||
};
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "plugins.tools_reachable_permissive_policy",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag plugin tool reachability when profile is restrictive", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-audit-plugins-"));
|
||||
const stateDir = path.join(tmp, "state");
|
||||
await fs.mkdir(path.join(stateDir, "extensions", "some-plugin"), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: { allow: ["some-plugin"] },
|
||||
tools: { profile: "coding" },
|
||||
};
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: false,
|
||||
stateDir,
|
||||
configPath: path.join(stateDir, "openclaw.json"),
|
||||
});
|
||||
|
||||
expect(
|
||||
res.findings.some((f) => f.checkId === "plugins.tools_reachable_permissive_policy"),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("flags unallowlisted extensions as critical when native skill commands are exposed", async () => {
|
||||
const prevDiscordToken = process.env.DISCORD_BOT_TOKEN;
|
||||
delete process.env.DISCORD_BOT_TOKEN;
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
collectHooksHardeningFindings,
|
||||
collectIncludeFilePermFindings,
|
||||
collectInstalledSkillsCodeSafetyFindings,
|
||||
collectMinimalProfileOverrideFindings,
|
||||
collectModelHygieneFindings,
|
||||
collectNodeDenyCommandPatternFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
collectSandboxDockerNoopFindings,
|
||||
collectPluginsTrustFindings,
|
||||
collectSecretsInConfigFindings,
|
||||
collectPluginsCodeSafetyFindings,
|
||||
@@ -980,6 +983,9 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
findings.push(...collectHooksHardeningFindings(cfg));
|
||||
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
||||
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
||||
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
||||
findings.push(...collectSecretsInConfigFindings(cfg));
|
||||
findings.push(...collectModelHygieneFindings(cfg));
|
||||
findings.push(...collectSmallModelRiskFindings({ cfg, env }));
|
||||
|
||||
Reference in New Issue
Block a user