mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
Tests: trim audit imports and fix reply typing
This commit is contained in:
@@ -63,12 +63,12 @@ function resolveRawConfiguredAcpSessionKey(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bindingAccountId = normalizeText(binding.match.accountId);
|
||||
const bindingAccountId = normalizeOptionalString(binding.match.accountId) ?? "";
|
||||
if (bindingAccountId && bindingAccountId !== "*" && bindingAccountId !== params.accountId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerId = normalizeText(binding.match.peer?.id);
|
||||
const peerId = normalizeOptionalString(binding.match.peer?.id) ?? "";
|
||||
const matchedConversationId =
|
||||
peerId === params.conversationId
|
||||
? params.conversationId
|
||||
@@ -107,7 +107,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
|
||||
fallbackToActiveAcpWhenUnbound?: boolean;
|
||||
}): string | undefined {
|
||||
const activeSessionKey = normalizeText(params.activeSessionKey);
|
||||
const activeSessionKey = normalizeOptionalString(params.activeSessionKey) ?? "";
|
||||
const activeAcpSessionKey =
|
||||
activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
|
||||
const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
|
||||
@@ -122,7 +122,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const parentConversationId = normalizeText(params.parentConversationId) || undefined;
|
||||
const parentConversationId = normalizeOptionalString(params.parentConversationId) || undefined;
|
||||
const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
|
||||
|
||||
const serviceBinding = acpResetTargetDeps.getSessionBindingService().resolveByConversation({
|
||||
|
||||
@@ -746,8 +746,9 @@ export async function dispatchReplyFromConfig(params: {
|
||||
message?: string;
|
||||
}) => {
|
||||
if (payload.status === "pending") {
|
||||
if (normalizeOptionalString(payload.command)) {
|
||||
return normalizeWorkingLabel(`awaiting approval: ${payload.command}`);
|
||||
const command = normalizeOptionalString(payload.command);
|
||||
if (command) {
|
||||
return normalizeWorkingLabel(`awaiting approval: ${command}`);
|
||||
}
|
||||
return "awaiting approval";
|
||||
}
|
||||
|
||||
@@ -77,8 +77,10 @@ function isApprovedElevatedSender(params: {
|
||||
const senderIdTokens = new Set<string>();
|
||||
const senderFromTokens = new Set<string>();
|
||||
const senderE164Tokens = new Set<string>();
|
||||
|
||||
const senderId = normalizeOptionalString(params.ctx.SenderId);
|
||||
const senderFrom = normalizeOptionalString(params.ctx.From);
|
||||
const senderE164 = normalizeOptionalString(params.ctx.SenderE164);
|
||||
|
||||
if (senderId) {
|
||||
addFormattedTokens({
|
||||
formatAllowFrom: params.formatAllowFrom,
|
||||
@@ -88,7 +90,6 @@ function isApprovedElevatedSender(params: {
|
||||
tokens: senderIdTokens,
|
||||
});
|
||||
}
|
||||
const senderFrom = normalizeOptionalString(params.ctx.From);
|
||||
if (senderFrom) {
|
||||
addFormattedTokens({
|
||||
formatAllowFrom: params.formatAllowFrom,
|
||||
@@ -98,7 +99,6 @@ function isApprovedElevatedSender(params: {
|
||||
tokens: senderFromTokens,
|
||||
});
|
||||
}
|
||||
const senderE164 = normalizeOptionalString(params.ctx.SenderE164);
|
||||
if (senderE164) {
|
||||
addFormattedTokens({
|
||||
formatAllowFrom: params.formatAllowFrom,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { listSupportedMusicGenerationModes } from "../music-generation/capabilit
|
||||
import {
|
||||
musicGenerationProviderContractRegistry,
|
||||
videoGenerationProviderContractRegistry,
|
||||
} from "../plugins/contracts/registry.js";
|
||||
} from "../plugins/contracts/media-provider-registry.js";
|
||||
import { listSupportedVideoGenerationModes } from "../video-generation/capabilities.js";
|
||||
|
||||
describe("bundled media-generation provider capabilities", () => {
|
||||
|
||||
92
src/plugins/contracts/media-provider-registry.ts
Normal file
92
src/plugins/contracts/media-provider-registry.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { loadBundledCapabilityRuntimeRegistry } from "../bundled-capability-runtime.js";
|
||||
import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js";
|
||||
import {
|
||||
loadVitestMusicGenerationProviderContractRegistry,
|
||||
loadVitestVideoGenerationProviderContractRegistry,
|
||||
type MusicGenerationProviderContractEntry,
|
||||
type VideoGenerationProviderContractEntry,
|
||||
} from "./speech-vitest-registry.js";
|
||||
|
||||
function resolveBundledManifestPluginIdsForContract(
|
||||
contract: "videoGenerationProviders" | "musicGenerationProviders",
|
||||
): string[] {
|
||||
return BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter((entry) =>
|
||||
contract === "videoGenerationProviders"
|
||||
? entry.videoGenerationProviderIds.length > 0
|
||||
: entry.musicGenerationProviderIds.length > 0,
|
||||
)
|
||||
.map((entry) => entry.pluginId)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function createLazyArrayView<T>(load: () => T[]): T[] {
|
||||
return new Proxy([] as T[], {
|
||||
get(_target, prop) {
|
||||
const actual = load();
|
||||
const value = Reflect.get(actual, prop, actual);
|
||||
return typeof value === "function" ? value.bind(actual) : value;
|
||||
},
|
||||
has(_target, prop) {
|
||||
return Reflect.has(load(), prop);
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(load());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, prop) {
|
||||
const actual = load();
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(actual, prop);
|
||||
if (descriptor) {
|
||||
return descriptor;
|
||||
}
|
||||
if (Reflect.has(actual, prop)) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
value: Reflect.get(actual, prop, actual),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let videoGenerationProviderContractRegistryCache: VideoGenerationProviderContractEntry[] | null =
|
||||
null;
|
||||
let musicGenerationProviderContractRegistryCache: MusicGenerationProviderContractEntry[] | null =
|
||||
null;
|
||||
|
||||
function loadVideoGenerationProviderContractRegistry(): VideoGenerationProviderContractEntry[] {
|
||||
if (!videoGenerationProviderContractRegistryCache) {
|
||||
videoGenerationProviderContractRegistryCache = process.env.VITEST
|
||||
? loadVitestVideoGenerationProviderContractRegistry()
|
||||
: loadBundledCapabilityRuntimeRegistry({
|
||||
pluginIds: resolveBundledManifestPluginIdsForContract("videoGenerationProviders"),
|
||||
pluginSdkResolution: "dist",
|
||||
}).videoGenerationProviders.map((entry) => ({
|
||||
pluginId: entry.pluginId,
|
||||
provider: entry.provider,
|
||||
}));
|
||||
}
|
||||
return videoGenerationProviderContractRegistryCache;
|
||||
}
|
||||
|
||||
function loadMusicGenerationProviderContractRegistry(): MusicGenerationProviderContractEntry[] {
|
||||
if (!musicGenerationProviderContractRegistryCache) {
|
||||
musicGenerationProviderContractRegistryCache = process.env.VITEST
|
||||
? loadVitestMusicGenerationProviderContractRegistry()
|
||||
: loadBundledCapabilityRuntimeRegistry({
|
||||
pluginIds: resolveBundledManifestPluginIdsForContract("musicGenerationProviders"),
|
||||
pluginSdkResolution: "dist",
|
||||
}).musicGenerationProviders.map((entry) => ({
|
||||
pluginId: entry.pluginId,
|
||||
provider: entry.provider,
|
||||
}));
|
||||
}
|
||||
return musicGenerationProviderContractRegistryCache;
|
||||
}
|
||||
|
||||
export const videoGenerationProviderContractRegistry: VideoGenerationProviderContractEntry[] =
|
||||
createLazyArrayView(loadVideoGenerationProviderContractRegistry);
|
||||
export const musicGenerationProviderContractRegistry: MusicGenerationProviderContractEntry[] =
|
||||
createLazyArrayView(loadMusicGenerationProviderContractRegistry);
|
||||
297
src/security/audit-extra.summary.ts
Normal file
297
src/security/audit-extra.summary.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
|
||||
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
|
||||
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "../config/model-input.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js";
|
||||
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
|
||||
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
};
|
||||
|
||||
const SMALL_MODEL_PARAM_B_MAX = 300;
|
||||
|
||||
type ModelRef = { id: string; source: string };
|
||||
|
||||
function summarizeGroupPolicy(cfg: OpenClawConfig): {
|
||||
open: number;
|
||||
allowlist: number;
|
||||
other: number;
|
||||
} {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return { open: 0, allowlist: 0, other: 0 };
|
||||
}
|
||||
let open = 0;
|
||||
let allowlist = 0;
|
||||
let other = 0;
|
||||
for (const value of Object.values(channels)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const section = value as Record<string, unknown>;
|
||||
const policy = section.groupPolicy;
|
||||
if (policy === "open") {
|
||||
open += 1;
|
||||
} else if (policy === "allowlist") {
|
||||
allowlist += 1;
|
||||
} else {
|
||||
other += 1;
|
||||
}
|
||||
}
|
||||
return { open, allowlist, other };
|
||||
}
|
||||
|
||||
function addModel(models: ModelRef[], raw: unknown, source: string) {
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const id = raw.trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
models.push({ id, source });
|
||||
}
|
||||
|
||||
function collectModels(cfg: OpenClawConfig): ModelRef[] {
|
||||
const out: ModelRef[] = [];
|
||||
addModel(
|
||||
out,
|
||||
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model),
|
||||
"agents.defaults.model.primary",
|
||||
);
|
||||
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
|
||||
addModel(out, fallback, "agents.defaults.model.fallbacks");
|
||||
}
|
||||
addModel(
|
||||
out,
|
||||
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel),
|
||||
"agents.defaults.imageModel.primary",
|
||||
);
|
||||
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
|
||||
addModel(out, fallback, "agents.defaults.imageModel.fallbacks");
|
||||
}
|
||||
|
||||
const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
|
||||
for (const agent of list ?? []) {
|
||||
if (!agent || typeof agent !== "object") {
|
||||
continue;
|
||||
}
|
||||
const id =
|
||||
typeof (agent as { id?: unknown }).id === "string" ? (agent as { id: string }).id : "";
|
||||
const model = (agent as { model?: unknown }).model;
|
||||
if (typeof model === "string") {
|
||||
addModel(out, model, `agents.list.${id}.model`);
|
||||
} else if (model && typeof model === "object") {
|
||||
addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`);
|
||||
const fallbacks = (model as { fallbacks?: unknown }).fallbacks;
|
||||
if (Array.isArray(fallbacks)) {
|
||||
for (const fallback of fallbacks) {
|
||||
addModel(out, fallback, `agents.list.${id}.model.fallbacks`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAgentIdFromSource(source: string): string | null {
|
||||
const match = source.match(/^agents\.list\.([^.]*)\./);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function resolveToolPolicies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentTools?: AgentToolsConfig;
|
||||
sandboxMode?: "off" | "non-main" | "all";
|
||||
agentId?: string | null;
|
||||
}): SandboxToolPolicy[] {
|
||||
const policies: SandboxToolPolicy[] = [];
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
if (profilePolicy) {
|
||||
policies.push(profilePolicy);
|
||||
}
|
||||
|
||||
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
|
||||
if (globalPolicy) {
|
||||
policies.push(globalPolicy);
|
||||
}
|
||||
|
||||
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
|
||||
if (agentPolicy) {
|
||||
policies.push(agentPolicy);
|
||||
}
|
||||
|
||||
if (params.sandboxMode === "all") {
|
||||
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
return hasConfiguredWebSearchCredential({
|
||||
config: cfg,
|
||||
env,
|
||||
origin: "bundled",
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
const enabled = cfg.tools?.web?.search?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
if (enabled === true) {
|
||||
return true;
|
||||
}
|
||||
return hasWebSearchKey(cfg, env);
|
||||
}
|
||||
|
||||
function isWebFetchEnabled(cfg: OpenClawConfig): boolean {
|
||||
const enabled = cfg.tools?.web?.fetch?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBrowserEnabled(cfg: OpenClawConfig): boolean {
|
||||
return cfg.browser?.enabled !== false;
|
||||
}
|
||||
|
||||
export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const group = summarizeGroupPolicy(cfg);
|
||||
const elevated = cfg.tools?.elevated?.enabled !== false;
|
||||
const webhooksEnabled = cfg.hooks?.enabled === true;
|
||||
const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false;
|
||||
const browserEnabled = cfg.browser?.enabled ?? true;
|
||||
|
||||
const detail =
|
||||
`groups: open=${group.open}, allowlist=${group.allowlist}` +
|
||||
`\n` +
|
||||
`tools.elevated: ${elevated ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
|
||||
|
||||
return [
|
||||
{
|
||||
checkId: "summary.attack_surface",
|
||||
severity: "info",
|
||||
title: "Attack surface summary",
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function collectSmallModelRiskFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel"));
|
||||
if (models.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const smallModels = models
|
||||
.map((entry) => {
|
||||
const paramB = inferParamBFromIdOrName(entry.id);
|
||||
if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, paramB };
|
||||
})
|
||||
.filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry));
|
||||
|
||||
if (smallModels.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
let hasUnsafe = false;
|
||||
const modelLines: string[] = [];
|
||||
const exposureSet = new Set<string>();
|
||||
for (const entry of smallModels) {
|
||||
const agentId = extractAgentIdFromSource(entry.source);
|
||||
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode;
|
||||
const agentTools =
|
||||
agentId && params.cfg.agents?.list
|
||||
? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools
|
||||
: undefined;
|
||||
const policies = resolveToolPolicies({
|
||||
cfg: params.cfg,
|
||||
agentTools,
|
||||
sandboxMode,
|
||||
agentId,
|
||||
});
|
||||
const exposed: string[] = [];
|
||||
if (
|
||||
isWebSearchEnabled(params.cfg, params.env) &&
|
||||
isToolAllowedByPolicies("web_search", policies)
|
||||
) {
|
||||
exposed.push("web_search");
|
||||
}
|
||||
if (isWebFetchEnabled(params.cfg) && isToolAllowedByPolicies("web_fetch", policies)) {
|
||||
exposed.push("web_fetch");
|
||||
}
|
||||
if (isBrowserEnabled(params.cfg) && isToolAllowedByPolicies("browser", policies)) {
|
||||
exposed.push("browser");
|
||||
}
|
||||
for (const tool of exposed) {
|
||||
exposureSet.add(tool);
|
||||
}
|
||||
const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`;
|
||||
const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]";
|
||||
const safe = sandboxMode === "all" && exposed.length === 0;
|
||||
if (!safe) {
|
||||
hasUnsafe = true;
|
||||
}
|
||||
const statusLabel = safe ? "ok" : "unsafe";
|
||||
modelLines.push(
|
||||
`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`,
|
||||
);
|
||||
}
|
||||
|
||||
const exposureList = Array.from(exposureSet);
|
||||
const exposureDetail =
|
||||
exposureList.length > 0
|
||||
? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.`
|
||||
: "No web/browser tools detected for these models.";
|
||||
|
||||
findings.push({
|
||||
checkId: "models.small_params",
|
||||
severity: hasUnsafe ? "critical" : "info",
|
||||
title: "Small models require sandboxing and web tools disabled",
|
||||
detail:
|
||||
`Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` +
|
||||
modelLines.join("\n") +
|
||||
`\n` +
|
||||
exposureDetail +
|
||||
`\n` +
|
||||
"Small models are not recommended for untrusted inputs.",
|
||||
remediation:
|
||||
'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).',
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
} from "./audit-extra.sync.js";
|
||||
} from "./audit-extra.summary.js";
|
||||
import { safeEqualSecret } from "./secret-equal.js";
|
||||
|
||||
describe("collectAttackSurfaceSummaryFindings", () => {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { isDangerousNetworkMode, normalizeNetworkMode } from "../agents/sandbox/
|
||||
*
|
||||
* These functions analyze config-based security properties without I/O.
|
||||
*/
|
||||
export {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
} from "./audit-extra.summary.js";
|
||||
import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js";
|
||||
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js";
|
||||
@@ -23,8 +27,6 @@ import {
|
||||
DEFAULT_DANGEROUS_NODE_COMMANDS,
|
||||
resolveNodeCommandAllowlist,
|
||||
} from "../gateway/node-command-policy.js";
|
||||
import { hasConfiguredWebSearchCredential } from "../plugins/web-search-credential-presence.js";
|
||||
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
|
||||
import { pickSandboxToolPolicy } from "./audit-tool-policy.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
@@ -35,41 +37,10 @@ export type SecurityAuditFinding = {
|
||||
remediation?: string;
|
||||
};
|
||||
|
||||
const SMALL_MODEL_PARAM_B_MAX = 300;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function summarizeGroupPolicy(cfg: OpenClawConfig): {
|
||||
open: number;
|
||||
allowlist: number;
|
||||
other: number;
|
||||
} {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return { open: 0, allowlist: 0, other: 0 };
|
||||
}
|
||||
let open = 0;
|
||||
let allowlist = 0;
|
||||
let other = 0;
|
||||
for (const value of Object.values(channels)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const section = value as Record<string, unknown>;
|
||||
const policy = section.groupPolicy;
|
||||
if (policy === "open") {
|
||||
open += 1;
|
||||
} else if (policy === "allowlist") {
|
||||
allowlist += 1;
|
||||
} else {
|
||||
other += 1;
|
||||
}
|
||||
}
|
||||
return { open, allowlist, other };
|
||||
}
|
||||
|
||||
function isProbablySyncedPath(p: string): boolean {
|
||||
const s = p.toLowerCase();
|
||||
return (
|
||||
@@ -86,15 +57,6 @@ function looksLikeEnvRef(value: string): boolean {
|
||||
return v.startsWith("${") && v.endsWith("}");
|
||||
}
|
||||
|
||||
function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
|
||||
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
|
||||
if (bind !== "loopback") {
|
||||
return true;
|
||||
}
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
return tailscaleMode === "serve" || tailscaleMode === "funnel";
|
||||
}
|
||||
|
||||
type ModelRef = { id: string; source: string };
|
||||
|
||||
function addModel(models: ModelRef[], raw: unknown, source: string) {
|
||||
@@ -115,16 +77,16 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
|
||||
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model),
|
||||
"agents.defaults.model.primary",
|
||||
);
|
||||
for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
|
||||
addModel(out, f, "agents.defaults.model.fallbacks");
|
||||
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)) {
|
||||
addModel(out, fallback, "agents.defaults.model.fallbacks");
|
||||
}
|
||||
addModel(
|
||||
out,
|
||||
resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageModel),
|
||||
"agents.defaults.imageModel.primary",
|
||||
);
|
||||
for (const f of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
|
||||
addModel(out, f, "agents.defaults.imageModel.fallbacks");
|
||||
for (const fallback of resolveAgentModelFallbackValues(cfg.agents?.defaults?.imageModel)) {
|
||||
addModel(out, fallback, "agents.defaults.imageModel.fallbacks");
|
||||
}
|
||||
|
||||
const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
|
||||
@@ -141,8 +103,8 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
|
||||
addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`);
|
||||
const fallbacks = (model as { fallbacks?: unknown }).fallbacks;
|
||||
if (Array.isArray(fallbacks)) {
|
||||
for (const f of fallbacks) {
|
||||
addModel(out, f, `agents.list.${id}.model.fallbacks`);
|
||||
for (const fallback of fallbacks) {
|
||||
addModel(out, fallback, `agents.list.${id}.model.fallbacks`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +112,15 @@ function collectModels(cfg: OpenClawConfig): ModelRef[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
|
||||
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
|
||||
if (bind !== "loopback") {
|
||||
return true;
|
||||
}
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
return tailscaleMode === "serve" || tailscaleMode === "funnel";
|
||||
}
|
||||
|
||||
const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
|
||||
{ id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" },
|
||||
{ id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" },
|
||||
@@ -179,11 +150,6 @@ function isClaude45OrHigher(id: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function extractAgentIdFromSource(source: string): string | null {
|
||||
const match = source.match(/^agents\.list\.([^.]*)\./);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function hasConfiguredDockerConfig(
|
||||
docker: Record<string, unknown> | undefined | null,
|
||||
): docker is Record<string, unknown> {
|
||||
@@ -221,6 +187,36 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set<string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveToolPolicies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentTools?: AgentToolsConfig;
|
||||
sandboxMode?: "off" | "non-main" | "all";
|
||||
agentId?: string | null;
|
||||
}): SandboxToolPolicy[] {
|
||||
const policies: SandboxToolPolicy[] = [];
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
if (profilePolicy) {
|
||||
policies.push(profilePolicy);
|
||||
}
|
||||
|
||||
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
|
||||
if (globalPolicy) {
|
||||
policies.push(globalPolicy);
|
||||
}
|
||||
|
||||
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
|
||||
if (agentPolicy) {
|
||||
policies.push(agentPolicy);
|
||||
}
|
||||
|
||||
if (params.sandboxMode === "all") {
|
||||
policies.push(resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined));
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
function looksLikeNodeCommandPattern(value: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
@@ -294,71 +290,6 @@ function suggestKnownNodeCommands(unknown: string, known: Set<string>): string[]
|
||||
.map((r) => r.cmd);
|
||||
}
|
||||
|
||||
function resolveToolPolicies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentTools?: AgentToolsConfig;
|
||||
sandboxMode?: "off" | "non-main" | "all";
|
||||
agentId?: string | null;
|
||||
}): SandboxToolPolicy[] {
|
||||
const policies: SandboxToolPolicy[] = [];
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
if (profilePolicy) {
|
||||
policies.push(profilePolicy);
|
||||
}
|
||||
|
||||
const globalPolicy = pickSandboxToolPolicy(params.cfg.tools ?? undefined);
|
||||
if (globalPolicy) {
|
||||
policies.push(globalPolicy);
|
||||
}
|
||||
|
||||
const agentPolicy = pickSandboxToolPolicy(params.agentTools);
|
||||
if (agentPolicy) {
|
||||
policies.push(agentPolicy);
|
||||
}
|
||||
|
||||
if (params.sandboxMode === "all") {
|
||||
const sandboxPolicy = resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined);
|
||||
policies.push(sandboxPolicy);
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
return hasConfiguredWebSearchCredential({
|
||||
config: cfg,
|
||||
env,
|
||||
origin: "bundled",
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
const enabled = cfg.tools?.web?.search?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
if (enabled === true) {
|
||||
return true;
|
||||
}
|
||||
return hasWebSearchKey(cfg, env);
|
||||
}
|
||||
|
||||
function isWebFetchEnabled(cfg: OpenClawConfig): boolean {
|
||||
const enabled = cfg.tools?.web?.fetch?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBrowserEnabled(cfg: OpenClawConfig): boolean {
|
||||
// The audit only needs the enablement policy, not full browser runtime
|
||||
// resolution. Browser defaults to enabled unless it is explicitly disabled.
|
||||
return cfg.browser?.enabled !== false;
|
||||
}
|
||||
|
||||
function listGroupPolicyOpen(cfg: OpenClawConfig): string[] {
|
||||
const out: string[] = [];
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
@@ -524,36 +455,6 @@ function collectRiskyToolExposureContexts(cfg: OpenClawConfig): {
|
||||
// Exported collectors
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const group = summarizeGroupPolicy(cfg);
|
||||
const elevated = cfg.tools?.elevated?.enabled !== false;
|
||||
const webhooksEnabled = cfg.hooks?.enabled === true;
|
||||
const internalHooksEnabled = cfg.hooks?.internal?.enabled !== false;
|
||||
const browserEnabled = cfg.browser?.enabled ?? true;
|
||||
|
||||
const detail =
|
||||
`groups: open=${group.open}, allowlist=${group.allowlist}` +
|
||||
`\n` +
|
||||
`tools.elevated: ${elevated ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`hooks.webhooks: ${webhooksEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`hooks.internal: ${internalHooksEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`browser control: ${browserEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
"trust model: personal assistant (one trusted operator boundary), not hostile multi-tenant on one shared gateway";
|
||||
|
||||
return [
|
||||
{
|
||||
checkId: "summary.attack_surface",
|
||||
severity: "info",
|
||||
title: "Attack surface summary",
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function collectSyncedFolderFindings(params: {
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
@@ -1194,101 +1095,6 @@ export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditF
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectSmallModelRiskFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel"));
|
||||
if (models.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const smallModels = models
|
||||
.map((entry) => {
|
||||
const paramB = inferParamBFromIdOrName(entry.id);
|
||||
if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, paramB };
|
||||
})
|
||||
.filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry));
|
||||
|
||||
if (smallModels.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
let hasUnsafe = false;
|
||||
const modelLines: string[] = [];
|
||||
const exposureSet = new Set<string>();
|
||||
for (const entry of smallModels) {
|
||||
const agentId = extractAgentIdFromSource(entry.source);
|
||||
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode;
|
||||
const agentTools =
|
||||
agentId && params.cfg.agents?.list
|
||||
? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools
|
||||
: undefined;
|
||||
const policies = resolveToolPolicies({
|
||||
cfg: params.cfg,
|
||||
agentTools,
|
||||
sandboxMode,
|
||||
agentId,
|
||||
});
|
||||
const exposed: string[] = [];
|
||||
if (isWebSearchEnabled(params.cfg, params.env)) {
|
||||
if (isToolAllowedByPolicies("web_search", policies)) {
|
||||
exposed.push("web_search");
|
||||
}
|
||||
}
|
||||
if (isWebFetchEnabled(params.cfg)) {
|
||||
if (isToolAllowedByPolicies("web_fetch", policies)) {
|
||||
exposed.push("web_fetch");
|
||||
}
|
||||
}
|
||||
if (isBrowserEnabled(params.cfg)) {
|
||||
if (isToolAllowedByPolicies("browser", policies)) {
|
||||
exposed.push("browser");
|
||||
}
|
||||
}
|
||||
for (const tool of exposed) {
|
||||
exposureSet.add(tool);
|
||||
}
|
||||
const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`;
|
||||
const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]";
|
||||
const safe = sandboxMode === "all" && exposed.length === 0;
|
||||
if (!safe) {
|
||||
hasUnsafe = true;
|
||||
}
|
||||
const statusLabel = safe ? "ok" : "unsafe";
|
||||
modelLines.push(
|
||||
`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`,
|
||||
);
|
||||
}
|
||||
|
||||
const exposureList = Array.from(exposureSet);
|
||||
const exposureDetail =
|
||||
exposureList.length > 0
|
||||
? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.`
|
||||
: "No web/browser tools detected for these models.";
|
||||
|
||||
findings.push({
|
||||
checkId: "models.small_params",
|
||||
severity: hasUnsafe ? "critical" : "info",
|
||||
title: "Small models require sandboxing and web tools disabled",
|
||||
detail:
|
||||
`Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` +
|
||||
modelLines.join("\n") +
|
||||
`\n` +
|
||||
exposureDetail +
|
||||
`\n` +
|
||||
"Small models are not recommended for untrusted inputs.",
|
||||
remediation:
|
||||
'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).',
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const openGroups = listGroupPolicyOpen(cfg);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectSmallModelRiskFindings } from "./audit-extra.sync.js";
|
||||
import { collectSmallModelRiskFindings } from "./audit-extra.summary.js";
|
||||
|
||||
describe("security audit small-model risk findings", () => {
|
||||
it("scores small-model risk by tool/sandbox exposure", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectAttackSurfaceSummaryFindings } from "./audit-extra.sync.js";
|
||||
import { collectAttackSurfaceSummaryFindings } from "./audit-extra.summary.js";
|
||||
|
||||
describe("security audit attack surface summary", () => {
|
||||
it("includes an attack surface summary (info)", () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
} from "./audit-extra.summary.js";
|
||||
|
||||
export {
|
||||
collectExposureMatrixFindings,
|
||||
collectGatewayHttpNoAuthFindings,
|
||||
collectGatewayHttpSessionKeyOverrideFindings,
|
||||
@@ -12,7 +16,6 @@ export {
|
||||
collectSandboxDangerousConfigFindings,
|
||||
collectSandboxDockerNoopFindings,
|
||||
collectSecretsInConfigFindings,
|
||||
collectSmallModelRiskFindings,
|
||||
collectSyncedFolderFindings,
|
||||
} from "./audit-extra.sync.js";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user