2026-02-22 11:05:27 +01:00
import { isIP } from "node:net" ;
2026-02-24 23:29:12 +00:00
import path from "node:path" ;
2026-02-19 15:33:25 +01:00
import { resolveSandboxConfigForAgent } from "../agents/sandbox.js" ;
2026-02-21 13:25:35 +01:00
import { execDockerRaw } from "../agents/sandbox/docker.js" ;
2026-01-26 22:59:02 -05:00
import { resolveBrowserConfig , resolveProfile } from "../browser/config.js" ;
2026-02-13 02:01:57 +01:00
import { resolveBrowserControlAuth } from "../browser/control-auth.js" ;
2026-02-01 10:03:47 +09:00
import { listChannelPlugins } from "../channels/plugins/index.js" ;
import { formatCliCommand } from "../cli/command-format.js" ;
2026-03-03 02:32:05 +00:00
import type { ConfigFileSnapshot , OpenClawConfig } from "../config/config.js" ;
2026-01-15 04:49:37 +00:00
import { resolveConfigPath , resolveStateDir } from "../config/paths.js" ;
2026-03-02 20:58:20 -06:00
import { hasConfiguredSecretInput } from "../config/types.secrets.js" ;
2026-01-15 01:25:11 +00:00
import { resolveGatewayAuth } from "../gateway/auth.js" ;
import { buildGatewayConnectionDetails } from "../gateway/call.js" ;
2026-03-05 12:53:56 -06:00
import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js" ;
2026-01-15 01:25:11 +00:00
import { probeGateway } from "../gateway/probe.js" ;
2026-02-22 13:18:17 +01:00
import {
listInterpreterLikeSafeBins ,
resolveMergedSafeBinProfileFixtures ,
} from "../infra/exec-safe-bin-runtime-policy.js" ;
2026-02-24 23:29:12 +00:00
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js" ;
2026-02-13 17:05:36 +00:00
import { collectChannelSecurityFindings } from "./audit-channel.js" ;
2026-01-15 05:31:35 +00:00
import {
collectAttackSurfaceSummaryFindings ,
collectExposureMatrixFindings ,
2026-02-19 14:25:45 +01:00
collectGatewayHttpNoAuthFindings ,
2026-02-14 06:32:17 -05:00
collectGatewayHttpSessionKeyOverrideFindings ,
2026-01-15 05:31:35 +00:00
collectHooksHardeningFindings ,
collectIncludeFilePermFindings ,
2026-02-05 17:06:11 -07:00
collectInstalledSkillsCodeSafetyFindings ,
2026-02-24 14:03:04 +00:00
collectLikelyMultiUserSetupFindings ,
2026-02-21 13:25:35 +01:00
collectSandboxBrowserHashLabelFindings ,
2026-02-13 16:26:37 +01:00
collectMinimalProfileOverrideFindings ,
2026-01-15 05:31:35 +00:00
collectModelHygieneFindings ,
2026-02-22 08:44:12 +01:00
collectNodeDangerousAllowCommandFindings ,
2026-02-13 16:26:37 +01:00
collectNodeDenyCommandPatternFindings ,
2026-01-20 23:45:50 +00:00
collectSmallModelRiskFindings ,
2026-02-16 03:03:55 +01:00
collectSandboxDangerousConfigFindings ,
2026-02-13 16:26:37 +01:00
collectSandboxDockerNoopFindings ,
2026-01-15 05:31:35 +00:00
collectPluginsTrustFindings ,
collectSecretsInConfigFindings ,
2026-02-05 17:06:11 -07:00
collectPluginsCodeSafetyFindings ,
2026-01-15 05:31:35 +00:00
collectStateDeepFilesystemFindings ,
collectSyncedFolderFindings ,
2026-03-02 23:28:46 +00:00
collectWorkspaceSkillSymlinkEscapeFindings ,
2026-01-15 05:31:35 +00:00
readConfigSnapshotForAudit ,
} from "./audit-extra.js" ;
import {
2026-01-26 18:19:58 +00:00
formatPermissionDetail ,
formatPermissionRemediation ,
inspectPathPermissions ,
2026-01-15 05:31:35 +00:00
} from "./audit-fs.js" ;
2026-02-22 10:11:03 +01:00
import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js" ;
2026-02-14 13:25:28 +01:00
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js" ;
2026-02-19 15:41:24 +01:00
import type { ExecFn } from "./windows-acl.js" ;
2026-01-15 01:25:11 +00:00
export type SecurityAuditSeverity = "info" | "warn" | "critical" ;
export type SecurityAuditFinding = {
checkId : string ;
severity : SecurityAuditSeverity ;
title : string ;
detail : string ;
remediation? : string ;
} ;
export type SecurityAuditSummary = {
critical : number ;
warn : number ;
info : number ;
} ;
export type SecurityAuditReport = {
ts : number ;
summary : SecurityAuditSummary ;
findings : SecurityAuditFinding [ ] ;
deep ? : {
gateway ? : {
attempted : boolean ;
url : string | null ;
ok : boolean ;
error : string | null ;
close ? : { code : number ; reason : string } | null ;
} ;
} ;
} ;
export type SecurityAuditOptions = {
2026-01-30 03:15:10 +01:00
config : OpenClawConfig ;
2026-03-05 23:07:13 -06:00
sourceConfig? : OpenClawConfig ;
2026-01-26 18:19:58 +00:00
env? : NodeJS.ProcessEnv ;
platform? : NodeJS.Platform ;
2026-01-15 01:25:11 +00:00
deep? : boolean ;
includeFilesystem? : boolean ;
includeChannelSecurity? : boolean ;
2026-01-15 04:50:11 +00:00
/** Override where to check state (default: resolveStateDir()). */
2026-01-15 01:25:11 +00:00
stateDir? : string ;
2026-01-15 04:50:11 +00:00
/** Override config path check (default: resolveConfigPath()). */
2026-01-15 01:25:11 +00:00
configPath? : string ;
/** Time limit for deep gateway probe. */
deepTimeoutMs? : number ;
/** Dependency injection for tests. */
plugins? : ReturnType < typeof listChannelPlugins > ;
/** Dependency injection for tests. */
probeGatewayFn? : typeof probeGateway ;
2026-01-26 18:19:58 +00:00
/** Dependency injection for tests (Windows ACL checks). */
execIcacls? : ExecFn ;
2026-02-21 13:25:35 +01:00
/** Dependency injection for tests (Docker label checks). */
execDockerRawFn? : typeof execDockerRaw ;
2026-03-03 02:32:05 +00:00
/** Optional preloaded config snapshot to skip audit-time config file reads. */
configSnapshot? : ConfigFileSnapshot | null ;
/** Optional cache for code-safety summaries across repeated deep audits. */
codeSafetySummaryCache? : Map < string , Promise < unknown > > ;
2026-01-15 01:25:11 +00:00
} ;
2026-03-03 02:42:35 +00:00
type AuditExecutionContext = {
cfg : OpenClawConfig ;
2026-03-05 23:07:13 -06:00
sourceConfig : OpenClawConfig ;
2026-03-03 02:42:35 +00:00
env : NodeJS.ProcessEnv ;
platform : NodeJS.Platform ;
includeFilesystem : boolean ;
includeChannelSecurity : boolean ;
deep : boolean ;
deepTimeoutMs : number ;
stateDir : string ;
configPath : string ;
execIcacls? : ExecFn ;
execDockerRawFn? : typeof execDockerRaw ;
probeGatewayFn? : typeof probeGateway ;
plugins? : ReturnType < typeof listChannelPlugins > ;
configSnapshot : ConfigFileSnapshot | null ;
codeSafetySummaryCache : Map < string , Promise < unknown > > ;
} ;
2026-01-15 01:25:11 +00:00
function countBySeverity ( findings : SecurityAuditFinding [ ] ) : SecurityAuditSummary {
let critical = 0 ;
let warn = 0 ;
let info = 0 ;
for ( const f of findings ) {
2026-01-31 16:19:20 +09:00
if ( f . severity === "critical" ) {
critical += 1 ;
} else if ( f . severity === "warn" ) {
warn += 1 ;
} else {
info += 1 ;
}
2026-01-15 01:25:11 +00:00
}
return { critical , warn , info } ;
}
function normalizeAllowFromList ( list : Array < string | number > | undefined | null ) : string [ ] {
2026-01-31 16:19:20 +09:00
if ( ! Array . isArray ( list ) ) {
return [ ] ;
}
2026-01-15 01:25:11 +00:00
return list . map ( ( v ) = > String ( v ) . trim ( ) ) . filter ( Boolean ) ;
}
2026-03-02 02:23:03 +00:00
function asRecord ( value : unknown ) : Record < string , unknown > | undefined {
if ( ! value || typeof value !== "object" || Array . isArray ( value ) ) {
return undefined ;
}
return value as Record < string , unknown > ;
}
function hasNonEmptyString ( value : unknown ) : boolean {
return typeof value === "string" && value . trim ( ) . length > 0 ;
}
function isFeishuDocToolEnabled ( cfg : OpenClawConfig ) : boolean {
const channels = asRecord ( cfg . channels ) ;
const feishu = asRecord ( channels ? . feishu ) ;
if ( ! feishu || feishu . enabled === false ) {
return false ;
}
const baseTools = asRecord ( feishu . tools ) ;
const baseDocEnabled = baseTools ? . doc !== false ;
const baseAppId = hasNonEmptyString ( feishu . appId ) ;
2026-03-02 20:58:20 -06:00
const baseAppSecret = hasConfiguredSecretInput ( feishu . appSecret , cfg . secrets ? . defaults ) ;
2026-03-02 02:23:03 +00:00
const baseConfigured = baseAppId && baseAppSecret ;
const accounts = asRecord ( feishu . accounts ) ;
if ( ! accounts || Object . keys ( accounts ) . length === 0 ) {
return baseDocEnabled && baseConfigured ;
}
for ( const accountValue of Object . values ( accounts ) ) {
const account = asRecord ( accountValue ) ? ? { } ;
if ( account . enabled === false ) {
continue ;
}
const accountTools = asRecord ( account . tools ) ;
const effectiveTools = accountTools ? ? baseTools ;
const docEnabled = effectiveTools ? . doc !== false ;
if ( ! docEnabled ) {
continue ;
}
const accountConfigured =
( hasNonEmptyString ( account . appId ) || baseAppId ) &&
2026-03-02 20:58:20 -06:00
( hasConfiguredSecretInput ( account . appSecret , cfg . secrets ? . defaults ) || baseAppSecret ) ;
2026-03-02 02:23:03 +00:00
if ( accountConfigured ) {
return true ;
}
}
return false ;
}
2026-01-15 01:25:11 +00:00
async function collectFilesystemFindings ( params : {
stateDir : string ;
configPath : string ;
2026-01-26 18:19:58 +00:00
env? : NodeJS.ProcessEnv ;
platform? : NodeJS.Platform ;
execIcacls? : ExecFn ;
2026-01-15 01:25:11 +00:00
} ) : Promise < SecurityAuditFinding [ ] > {
const findings : SecurityAuditFinding [ ] = [ ] ;
2026-01-26 18:19:58 +00:00
const stateDirPerms = await inspectPathPermissions ( params . stateDir , {
env : params.env ,
platform : params.platform ,
exec : params.execIcacls ,
} ) ;
if ( stateDirPerms . ok ) {
if ( stateDirPerms . isSymlink ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.state_dir.symlink" ,
severity : "warn" ,
title : "State dir is a symlink" ,
detail : ` ${ params . stateDir } is a symlink; treat this as an extra trust boundary. ` ,
} ) ;
}
2026-01-26 18:19:58 +00:00
if ( stateDirPerms . worldWritable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.state_dir.perms_world_writable" ,
severity : "critical" ,
title : "State dir is world-writable" ,
2026-01-30 03:15:10 +01:00
detail : ` ${ formatPermissionDetail ( params . stateDir , stateDirPerms ) } ; other users can write into your OpenClaw state. ` ,
2026-01-26 18:19:58 +00:00
remediation : formatPermissionRemediation ( {
targetPath : params.stateDir ,
perms : stateDirPerms ,
isDir : true ,
posixMode : 0o700 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
2026-01-26 18:19:58 +00:00
} else if ( stateDirPerms . groupWritable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.state_dir.perms_group_writable" ,
severity : "warn" ,
title : "State dir is group-writable" ,
2026-01-30 03:15:10 +01:00
detail : ` ${ formatPermissionDetail ( params . stateDir , stateDirPerms ) } ; group users can write into your OpenClaw state. ` ,
2026-01-26 18:19:58 +00:00
remediation : formatPermissionRemediation ( {
targetPath : params.stateDir ,
perms : stateDirPerms ,
isDir : true ,
posixMode : 0o700 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
2026-01-26 18:19:58 +00:00
} else if ( stateDirPerms . groupReadable || stateDirPerms . worldReadable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.state_dir.perms_readable" ,
severity : "warn" ,
title : "State dir is readable by others" ,
2026-01-26 18:19:58 +00:00
detail : ` ${ formatPermissionDetail ( params . stateDir , stateDirPerms ) } ; consider restricting to 700. ` ,
remediation : formatPermissionRemediation ( {
targetPath : params.stateDir ,
perms : stateDirPerms ,
isDir : true ,
posixMode : 0o700 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
}
}
2026-01-26 18:19:58 +00:00
const configPerms = await inspectPathPermissions ( params . configPath , {
env : params.env ,
platform : params.platform ,
exec : params.execIcacls ,
} ) ;
if ( configPerms . ok ) {
2026-02-18 00:09:51 -08:00
const skipReadablePermWarnings = configPerms . isSymlink ;
2026-01-26 18:19:58 +00:00
if ( configPerms . isSymlink ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.config.symlink" ,
severity : "warn" ,
title : "Config file is a symlink" ,
detail : ` ${ params . configPath } is a symlink; make sure you trust its target. ` ,
} ) ;
}
2026-01-26 18:19:58 +00:00
if ( configPerms . worldWritable || configPerms . groupWritable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.config.perms_writable" ,
severity : "critical" ,
title : "Config file is writable by others" ,
2026-01-26 18:19:58 +00:00
detail : ` ${ formatPermissionDetail ( params . configPath , configPerms ) } ; another user could change gateway/auth/tool policies. ` ,
remediation : formatPermissionRemediation ( {
targetPath : params.configPath ,
perms : configPerms ,
isDir : false ,
posixMode : 0o600 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
2026-02-18 00:09:51 -08:00
} else if ( ! skipReadablePermWarnings && configPerms . worldReadable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.config.perms_world_readable" ,
severity : "critical" ,
title : "Config file is world-readable" ,
2026-01-26 18:19:58 +00:00
detail : ` ${ formatPermissionDetail ( params . configPath , configPerms ) } ; config can contain tokens and private settings. ` ,
remediation : formatPermissionRemediation ( {
targetPath : params.configPath ,
perms : configPerms ,
isDir : false ,
posixMode : 0o600 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
2026-02-18 00:09:51 -08:00
} else if ( ! skipReadablePermWarnings && configPerms . groupReadable ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "fs.config.perms_group_readable" ,
severity : "warn" ,
title : "Config file is group-readable" ,
2026-01-26 18:19:58 +00:00
detail : ` ${ formatPermissionDetail ( params . configPath , configPerms ) } ; config can contain tokens and private settings. ` ,
remediation : formatPermissionRemediation ( {
targetPath : params.configPath ,
perms : configPerms ,
isDir : false ,
posixMode : 0o600 ,
env : params.env ,
} ) ,
2026-01-15 01:25:11 +00:00
} ) ;
}
}
return findings ;
}
2026-01-26 15:26:15 -08:00
function collectGatewayConfigFindings (
2026-01-30 03:15:10 +01:00
cfg : OpenClawConfig ,
2026-01-26 15:26:15 -08:00
env : NodeJS.ProcessEnv ,
) : SecurityAuditFinding [ ] {
2026-01-15 01:25:11 +00:00
const findings : SecurityAuditFinding [ ] = [ ] ;
const bind = typeof cfg . gateway ? . bind === "string" ? cfg . gateway . bind : "loopback" ;
const tailscaleMode = cfg . gateway ? . tailscale ? . mode ? ? "off" ;
2026-01-26 15:26:15 -08:00
const auth = resolveGatewayAuth ( { authConfig : cfg.gateway?.auth , tailscaleMode , env } ) ;
2026-01-26 02:08:03 +11:00
const controlUiEnabled = cfg . gateway ? . controlUi ? . enabled !== false ;
2026-02-24 01:52:15 +00:00
const controlUiAllowedOrigins = ( cfg . gateway ? . controlUi ? . allowedOrigins ? ? [ ] )
. map ( ( value ) = > value . trim ( ) )
. filter ( Boolean ) ;
const dangerouslyAllowHostHeaderOriginFallback =
cfg . gateway ? . controlUi ? . dangerouslyAllowHostHeaderOriginFallback === true ;
2026-01-26 02:08:03 +11:00
const trustedProxies = Array . isArray ( cfg . gateway ? . trustedProxies )
? cfg . gateway . trustedProxies
: [ ] ;
2026-01-26 12:56:33 +00:00
const hasToken = typeof auth . token === "string" && auth . token . trim ( ) . length > 0 ;
const hasPassword = typeof auth . password === "string" && auth . password . trim ( ) . length > 0 ;
2026-03-02 20:58:20 -06:00
const envTokenConfigured =
hasNonEmptyString ( env . OPENCLAW_GATEWAY_TOKEN ) || hasNonEmptyString ( env . CLAWDBOT_GATEWAY_TOKEN ) ;
const envPasswordConfigured =
hasNonEmptyString ( env . OPENCLAW_GATEWAY_PASSWORD ) ||
hasNonEmptyString ( env . CLAWDBOT_GATEWAY_PASSWORD ) ;
const tokenConfiguredFromConfig = hasConfiguredSecretInput (
cfg . gateway ? . auth ? . token ,
cfg . secrets ? . defaults ,
) ;
const passwordConfiguredFromConfig = hasConfiguredSecretInput (
cfg . gateway ? . auth ? . password ,
cfg . secrets ? . defaults ,
) ;
const remoteTokenConfigured = hasConfiguredSecretInput (
cfg . gateway ? . remote ? . token ,
cfg . secrets ? . defaults ,
) ;
const explicitAuthMode = cfg . gateway ? . auth ? . mode ;
const tokenCanWin =
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured ;
const passwordCanWin =
explicitAuthMode === "password" ||
( explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
! tokenCanWin ) ;
const tokenConfigured = tokenCanWin ;
const passwordConfigured =
hasPassword || ( passwordCanWin && ( envPasswordConfigured || passwordConfiguredFromConfig ) ) ;
2026-01-26 12:56:33 +00:00
const hasSharedSecret =
2026-03-02 20:58:20 -06:00
explicitAuthMode === "token"
? tokenConfigured
: explicitAuthMode === "password"
? passwordConfigured
: explicitAuthMode === "none" || explicitAuthMode === "trusted-proxy"
? false
: tokenConfigured || passwordConfigured ;
2026-01-31 16:03:28 +09:00
const hasTailscaleAuth = auth . allowTailscale && tailscaleMode === "serve" ;
2026-01-26 12:56:33 +00:00
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth ;
2026-02-22 02:38:58 -07:00
const allowRealIpFallback = cfg . gateway ? . allowRealIpFallback === true ;
const mdnsMode = cfg . discovery ? . mdns ? . mode ? ? "minimal" ;
2026-02-14 12:44:43 +01:00
// HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations.
// If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit.
const gatewayToolsAllowRaw = Array . isArray ( cfg . gateway ? . tools ? . allow )
? cfg . gateway ? . tools ? . allow
: [ ] ;
const gatewayToolsAllow = new Set (
gatewayToolsAllowRaw
. map ( ( v ) = > ( typeof v === "string" ? v . trim ( ) . toLowerCase ( ) : "" ) )
. filter ( Boolean ) ,
) ;
2026-02-14 13:25:28 +01:00
const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY . filter ( ( name ) = >
gatewayToolsAllow . has ( name ) ,
) ;
2026-02-14 12:44:43 +01:00
if ( reenabledOverHttp . length > 0 ) {
const extraRisk = bind !== "loopback" || tailscaleMode === "funnel" ;
findings . push ( {
checkId : "gateway.tools_invoke_http.dangerous_allow" ,
severity : extraRisk ? "critical" : "warn" ,
title : "Gateway HTTP /tools/invoke re-enables dangerous tools" ,
detail :
` gateway.tools.allow includes ${ reenabledOverHttp . join ( ", " ) } which removes them from the default HTTP deny list. ` +
"This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable." ,
remediation :
"Remove these entries from gateway.tools.allow (recommended). " +
"If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin." ,
} ) ;
}
2026-02-14 06:32:17 -05:00
if ( bind !== "loopback" && ! hasSharedSecret && auth . mode !== "trusted-proxy" ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "gateway.bind_no_auth" ,
severity : "critical" ,
title : "Gateway binds beyond loopback without auth" ,
detail : ` gateway.bind=" ${ bind } " but no gateway.auth token/password is configured. ` ,
remediation : ` Set gateway.auth (token recommended) or bind to loopback. ` ,
} ) ;
}
2026-01-26 02:08:03 +11:00
if ( bind === "loopback" && controlUiEnabled && trustedProxies . length === 0 ) {
findings . push ( {
checkId : "gateway.trusted_proxies_missing" ,
severity : "warn" ,
title : "Reverse proxy headers are not trusted" ,
detail :
"gateway.bind is loopback and gateway.trustedProxies is empty. " +
"If you expose the Control UI through a reverse proxy, configure trusted proxies " +
"so local-client checks cannot be spoofed." ,
remediation :
"Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only." ,
} ) ;
}
2026-01-26 12:56:33 +00:00
if ( bind === "loopback" && controlUiEnabled && ! hasGatewayAuth ) {
2026-01-25 15:16:40 +00:00
findings . push ( {
checkId : "gateway.loopback_no_auth" ,
severity : "critical" ,
2026-01-26 12:56:33 +00:00
title : "Gateway auth missing on loopback" ,
2026-01-25 15:16:40 +00:00
detail :
2026-01-26 12:56:33 +00:00
"gateway.bind is loopback but no gateway auth secret is configured. " +
2026-01-25 15:16:40 +00:00
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible." ,
remediation : "Set gateway.auth (token recommended) or keep the Control UI local-only." ,
} ) ;
}
2026-02-24 01:52:15 +00:00
if (
bind !== "loopback" &&
controlUiEnabled &&
controlUiAllowedOrigins . length === 0 &&
! dangerouslyAllowHostHeaderOriginFallback
) {
findings . push ( {
checkId : "gateway.control_ui.allowed_origins_required" ,
severity : "critical" ,
title : "Non-loopback Control UI missing explicit allowed origins" ,
detail :
"Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " +
"Strict origin policy requires explicit allowed origins for non-loopback deployments." ,
remediation :
"Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " +
"If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true." ,
} ) ;
}
2026-03-02 02:23:03 +00:00
if ( controlUiAllowedOrigins . includes ( "*" ) ) {
const exposed = bind !== "loopback" ;
findings . push ( {
checkId : "gateway.control_ui.allowed_origins_wildcard" ,
severity : exposed ? "critical" : "warn" ,
title : "Control UI allowed origins contains wildcard" ,
detail :
'gateway.controlUi.allowedOrigins includes "*" which effectively disables origin allowlisting for Control UI/WebChat requests.' ,
remediation :
"Replace wildcard origins with explicit trusted origins (for example https://control.example.com)." ,
} ) ;
}
2026-02-24 01:52:15 +00:00
if ( dangerouslyAllowHostHeaderOriginFallback ) {
const exposed = bind !== "loopback" ;
findings . push ( {
checkId : "gateway.control_ui.host_header_origin_fallback" ,
severity : exposed ? "critical" : "warn" ,
title : "DANGEROUS: Host-header origin fallback enabled" ,
detail :
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " +
"for Control UI/WebChat websocket checks and weakens DNS rebinding protections." ,
remediation :
"Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins." ,
} ) ;
}
2026-01-25 15:16:40 +00:00
2026-02-22 02:38:58 -07:00
if ( allowRealIpFallback ) {
2026-02-22 11:05:27 +01:00
const hasNonLoopbackTrustedProxy = trustedProxies . some (
2026-02-22 11:34:56 +01:00
( proxy ) = > ! isStrictLoopbackTrustedProxyEntry ( proxy ) ,
2026-02-22 11:05:27 +01:00
) ;
const exposed =
bind !== "loopback" || ( auth . mode === "trusted-proxy" && hasNonLoopbackTrustedProxy ) ;
2026-02-22 02:38:58 -07:00
findings . push ( {
checkId : "gateway.real_ip_fallback_enabled" ,
severity : exposed ? "critical" : "warn" ,
title : "X-Real-IP fallback is enabled" ,
detail :
"gateway.allowRealIpFallback=true trusts X-Real-IP when trusted proxies omit X-Forwarded-For. " +
"Misconfigured proxies that forward client-supplied X-Real-IP can spoof source IP and local-client checks." ,
remediation :
"Keep gateway.allowRealIpFallback=false (default). Only enable this when your trusted proxy " +
"always overwrites X-Real-IP and cannot provide X-Forwarded-For." ,
} ) ;
}
if ( mdnsMode === "full" ) {
const exposed = bind !== "loopback" ;
findings . push ( {
checkId : "discovery.mdns_full_mode" ,
severity : exposed ? "critical" : "warn" ,
title : "mDNS full mode can leak host metadata" ,
detail :
'discovery.mdns.mode="full" publishes cliPath/sshPort in local-network TXT records. ' +
"This can reveal usernames, filesystem layout, and management ports." ,
remediation :
'Prefer discovery.mdns.mode="minimal" (recommended) or "off", especially when gateway.bind is not loopback.' ,
} ) ;
}
2026-01-15 01:25:11 +00:00
if ( tailscaleMode === "funnel" ) {
findings . push ( {
checkId : "gateway.tailscale_funnel" ,
severity : "critical" ,
title : "Tailscale Funnel exposure enabled" ,
detail : ` gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing. ` ,
remediation : ` Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off". ` ,
} ) ;
} else if ( tailscaleMode === "serve" ) {
findings . push ( {
checkId : "gateway.tailscale_serve" ,
severity : "info" ,
title : "Tailscale Serve exposure enabled" ,
detail : ` gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale). ` ,
} ) ;
}
2026-01-21 23:58:30 +00:00
if ( cfg . gateway ? . controlUi ? . allowInsecureAuth === true ) {
findings . push ( {
checkId : "gateway.control_ui.insecure_auth" ,
2026-02-21 13:17:44 +01:00
severity : "warn" ,
2026-02-21 12:55:18 +01:00
title : "Control UI insecure auth toggle enabled" ,
2026-01-21 23:58:30 +00:00
detail :
2026-02-21 12:55:18 +01:00
"gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks." ,
2026-01-21 23:58:30 +00:00
remediation : "Disable it or switch to HTTPS (Tailscale Serve) or localhost." ,
} ) ;
}
2026-01-26 17:40:24 +00:00
if ( cfg . gateway ? . controlUi ? . dangerouslyDisableDeviceAuth === true ) {
findings . push ( {
checkId : "gateway.control_ui.device_auth_disabled" ,
severity : "critical" ,
title : "DANGEROUS: Control UI device auth disabled" ,
detail :
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI." ,
remediation : "Disable it unless you are in a short-lived break-glass scenario." ,
} ) ;
}
2026-03-02 02:23:03 +00:00
if ( isFeishuDocToolEnabled ( cfg ) ) {
findings . push ( {
checkId : "channels.feishu.doc_owner_open_id" ,
severity : "warn" ,
2026-03-01 20:51:45 -06:00
title : "Feishu doc create can grant requester permissions" ,
2026-03-02 02:23:03 +00:00
detail :
2026-03-01 20:51:45 -06:00
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.' ,
2026-03-02 02:23:03 +00:00
remediation :
2026-03-01 20:51:45 -06:00
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts." ,
2026-03-02 02:23:03 +00:00
} ) ;
}
2026-02-21 13:17:44 +01:00
const enabledDangerousFlags = collectEnabledInsecureOrDangerousFlags ( cfg ) ;
if ( enabledDangerousFlags . length > 0 ) {
findings . push ( {
checkId : "config.insecure_or_dangerous_flags" ,
severity : "warn" ,
title : "Insecure or dangerous config flags enabled" ,
detail : ` Detected ${ enabledDangerousFlags . length } enabled flag(s): ${ enabledDangerousFlags . join ( ", " ) } . ` ,
remediation :
"Disable these flags when not actively debugging, or keep deployment scoped to trusted/local-only networks." ,
} ) ;
}
2026-01-15 01:25:11 +00:00
const token =
typeof auth . token === "string" && auth . token . trim ( ) . length > 0 ? auth . token . trim ( ) : null ;
if ( auth . mode === "token" && token && token . length < 24 ) {
findings . push ( {
checkId : "gateway.token_too_short" ,
severity : "warn" ,
title : "Gateway token looks short" ,
detail : ` gateway auth token is ${ token . length } chars; prefer a long random token. ` ,
} ) ;
}
2026-02-14 06:32:17 -05:00
if ( auth . mode === "trusted-proxy" ) {
const trustedProxies = cfg . gateway ? . trustedProxies ? ? [ ] ;
const trustedProxyConfig = cfg . gateway ? . auth ? . trustedProxy ;
2026-02-13 02:09:01 +01:00
findings . push ( {
2026-02-14 06:32:17 -05:00
checkId : "gateway.trusted_proxy_auth" ,
severity : "critical" ,
title : "Trusted-proxy auth mode enabled" ,
2026-02-13 02:09:01 +01:00
detail :
2026-02-14 06:32:17 -05:00
'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' +
"Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " +
"only contains IPs of your actual proxy servers." ,
2026-02-13 02:09:01 +01:00
remediation :
2026-02-14 06:32:17 -05:00
"Verify: (1) Your proxy terminates TLS and authenticates users. " +
"(2) gateway.trustedProxies is restricted to proxy IPs only. " +
"(3) Direct access to the Gateway port is blocked by firewall. " +
"See /gateway/trusted-proxy-auth for setup guidance." ,
2026-02-13 02:09:01 +01:00
} ) ;
2026-02-14 06:32:17 -05:00
if ( trustedProxies . length === 0 ) {
findings . push ( {
checkId : "gateway.trusted_proxy_no_proxies" ,
severity : "critical" ,
title : "Trusted-proxy auth enabled but no trusted proxies configured" ,
detail :
'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' +
"All requests will be rejected." ,
remediation : "Set gateway.trustedProxies to the IP(s) of your reverse proxy." ,
} ) ;
}
if ( ! trustedProxyConfig ? . userHeader ) {
findings . push ( {
checkId : "gateway.trusted_proxy_no_user_header" ,
severity : "critical" ,
title : "Trusted-proxy auth missing userHeader config" ,
detail :
'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.' ,
remediation :
"Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " +
'(e.g., "x-forwarded-user", "x-pomerium-claim-email").' ,
} ) ;
}
const allowUsers = trustedProxyConfig ? . allowUsers ? ? [ ] ;
if ( allowUsers . length === 0 ) {
findings . push ( {
checkId : "gateway.trusted_proxy_no_allowlist" ,
severity : "warn" ,
title : "Trusted-proxy auth allows all authenticated users" ,
detail :
"gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway." ,
remediation :
"Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " +
'(e.g., ["nick@example.com"]).' ,
} ) ;
}
2026-02-13 02:09:01 +01:00
}
2026-02-14 06:32:17 -05:00
if ( bind !== "loopback" && auth . mode !== "trusted-proxy" && ! cfg . gateway ? . auth ? . rateLimit ) {
2026-02-13 15:32:38 +01:00
findings . push ( {
checkId : "gateway.auth_no_rate_limit" ,
severity : "warn" ,
title : "No auth rate limiting configured" ,
detail :
"gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " +
"Without rate limiting, brute-force auth attacks are not mitigated." ,
remediation :
"Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 })." ,
} ) ;
}
2026-01-15 01:25:11 +00:00
return findings ;
}
2026-02-22 11:34:56 +01:00
// Keep this stricter than isLoopbackAddress on purpose: this check is for
// trust boundaries, so only explicit localhost proxy hops are treated as local.
function isStrictLoopbackTrustedProxyEntry ( entry : string ) : boolean {
2026-02-22 11:05:27 +01:00
const candidate = entry . trim ( ) ;
if ( ! candidate ) {
return false ;
}
if ( ! candidate . includes ( "/" ) ) {
2026-02-22 11:34:56 +01:00
return candidate === "127.0.0.1" || candidate . toLowerCase ( ) === "::1" ;
2026-02-22 11:05:27 +01:00
}
const [ rawIp , rawPrefix ] = candidate . split ( "/" , 2 ) ;
if ( ! rawIp || ! rawPrefix ) {
return false ;
}
const ipVersion = isIP ( rawIp . trim ( ) ) ;
const prefix = Number . parseInt ( rawPrefix . trim ( ) , 10 ) ;
if ( ! Number . isInteger ( prefix ) ) {
return false ;
}
if ( ipVersion === 4 ) {
2026-02-22 11:34:56 +01:00
return rawIp . trim ( ) === "127.0.0.1" && prefix === 32 ;
2026-02-22 11:05:27 +01:00
}
if ( ipVersion === 6 ) {
return prefix === 128 && rawIp . trim ( ) . toLowerCase ( ) === "::1" ;
}
return false ;
}
2026-02-13 02:01:57 +01:00
function collectBrowserControlFindings (
cfg : OpenClawConfig ,
env : NodeJS.ProcessEnv ,
) : SecurityAuditFinding [ ] {
2026-01-15 04:50:11 +00:00
const findings : SecurityAuditFinding [ ] = [ ] ;
let resolved : ReturnType < typeof resolveBrowserConfig > ;
try {
2026-01-27 03:23:42 +00:00
resolved = resolveBrowserConfig ( cfg . browser , cfg ) ;
2026-01-15 04:50:11 +00:00
} catch ( err ) {
findings . push ( {
checkId : "browser.control_invalid_config" ,
severity : "warn" ,
title : "Browser control config looks invalid" ,
detail : String ( err ) ,
2026-01-30 03:15:10 +01:00
remediation : ` Fix browser.cdpUrl in ${ resolveConfigPath ( ) } and re-run " ${ formatCliCommand ( "openclaw security audit --deep" ) } ". ` ,
2026-01-15 04:50:11 +00:00
} ) ;
return findings ;
}
2026-01-31 16:19:20 +09:00
if ( ! resolved . enabled ) {
return findings ;
}
2026-01-15 04:50:11 +00:00
2026-02-13 02:01:57 +01:00
const browserAuth = resolveBrowserControlAuth ( cfg , env ) ;
2026-03-02 20:58:20 -06:00
const explicitAuthMode = cfg . gateway ? . auth ? . mode ;
const tokenConfigured =
Boolean ( browserAuth . token ) ||
hasNonEmptyString ( env . OPENCLAW_GATEWAY_TOKEN ) ||
hasNonEmptyString ( env . CLAWDBOT_GATEWAY_TOKEN ) ||
hasConfiguredSecretInput ( cfg . gateway ? . auth ? . token , cfg . secrets ? . defaults ) ;
const passwordCanWin =
explicitAuthMode === "password" ||
( explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
! tokenConfigured ) ;
const passwordConfigured =
Boolean ( browserAuth . password ) ||
( passwordCanWin &&
( hasNonEmptyString ( env . OPENCLAW_GATEWAY_PASSWORD ) ||
hasNonEmptyString ( env . CLAWDBOT_GATEWAY_PASSWORD ) ||
hasConfiguredSecretInput ( cfg . gateway ? . auth ? . password , cfg . secrets ? . defaults ) ) ) ;
if ( ! tokenConfigured && ! passwordConfigured ) {
2026-02-13 02:01:57 +01:00
findings . push ( {
checkId : "browser.control_no_auth" ,
severity : "critical" ,
title : "Browser control has no auth" ,
detail :
"Browser control HTTP routes are enabled but no gateway.auth token/password is configured. " +
"Any local process (or SSRF to loopback) can call browser control endpoints." ,
remediation :
"Set gateway.auth.token (recommended) or gateway.auth.password so browser control HTTP routes require authentication. Restarting the gateway will auto-generate gateway.auth.token when browser control is enabled." ,
} ) ;
}
2026-01-27 03:23:42 +00:00
for ( const name of Object . keys ( resolved . profiles ) ) {
const profile = resolveProfile ( resolved , name ) ;
2026-01-31 16:19:20 +09:00
if ( ! profile || profile . cdpIsLoopback ) {
continue ;
}
2026-01-27 03:23:42 +00:00
let url : URL ;
try {
url = new URL ( profile . cdpUrl ) ;
} catch {
continue ;
2026-01-15 04:50:11 +00:00
}
if ( url . protocol === "http:" ) {
findings . push ( {
2026-01-27 03:23:42 +00:00
checkId : "browser.remote_cdp_http" ,
2026-01-15 04:50:11 +00:00
severity : "warn" ,
2026-01-27 03:23:42 +00:00
title : "Remote CDP uses HTTP" ,
detail : ` browser profile " ${ name } " uses http CDP ( ${ profile . cdpUrl } ); this is OK only if it's tailnet-only or behind an encrypted tunnel. ` ,
remediation : ` Prefer HTTPS/TLS or a tailnet-only endpoint for remote CDP. ` ,
2026-01-15 04:50:11 +00:00
} ) ;
}
}
return findings ;
}
2026-01-30 03:15:10 +01:00
function collectLoggingFindings ( cfg : OpenClawConfig ) : SecurityAuditFinding [ ] {
2026-01-15 01:25:11 +00:00
const redact = cfg . logging ? . redactSensitive ;
2026-01-31 16:19:20 +09:00
if ( redact !== "off" ) {
return [ ] ;
}
2026-01-15 01:25:11 +00:00
return [
{
checkId : "logging.redact_off" ,
severity : "warn" ,
title : "Tool summary redaction is disabled" ,
detail : ` logging.redactSensitive="off" can leak secrets into logs and status output. ` ,
remediation : ` Set logging.redactSensitive="tools". ` ,
} ,
] ;
}
2026-01-30 03:15:10 +01:00
function collectElevatedFindings ( cfg : OpenClawConfig ) : SecurityAuditFinding [ ] {
2026-01-15 01:25:11 +00:00
const findings : SecurityAuditFinding [ ] = [ ] ;
const enabled = cfg . tools ? . elevated ? . enabled ;
const allowFrom = cfg . tools ? . elevated ? . allowFrom ? ? { } ;
const anyAllowFromKeys = Object . keys ( allowFrom ) . length > 0 ;
2026-01-31 16:19:20 +09:00
if ( enabled === false ) {
return findings ;
}
if ( ! anyAllowFromKeys ) {
return findings ;
}
2026-01-15 01:25:11 +00:00
for ( const [ provider , list ] of Object . entries ( allowFrom ) ) {
const normalized = normalizeAllowFromList ( list ) ;
if ( normalized . includes ( "*" ) ) {
findings . push ( {
checkId : ` tools.elevated.allowFrom. ${ provider } .wildcard ` ,
severity : "critical" ,
title : "Elevated exec allowlist contains wildcard" ,
detail : ` tools.elevated.allowFrom. ${ provider } includes "*" which effectively approves everyone on that channel for elevated mode. ` ,
} ) ;
} else if ( normalized . length > 25 ) {
findings . push ( {
checkId : ` tools.elevated.allowFrom. ${ provider } .large ` ,
severity : "warn" ,
title : "Elevated exec allowlist is large" ,
detail : ` tools.elevated.allowFrom. ${ provider } has ${ normalized . length } entries; consider tightening elevated access. ` ,
} ) ;
}
}
return findings ;
}
2026-02-19 15:33:25 +01:00
function collectExecRuntimeFindings ( cfg : OpenClawConfig ) : SecurityAuditFinding [ ] {
const findings : SecurityAuditFinding [ ] = [ ] ;
const globalExecHost = cfg . tools ? . exec ? . host ;
const defaultSandboxMode = resolveSandboxConfigForAgent ( cfg ) . mode ;
const defaultHostIsExplicitSandbox = globalExecHost === "sandbox" ;
if ( defaultHostIsExplicitSandbox && defaultSandboxMode === "off" ) {
findings . push ( {
checkId : "tools.exec.host_sandbox_no_sandbox_defaults" ,
severity : "warn" ,
title : "Exec host is sandbox but sandbox mode is off" ,
detail :
"tools.exec.host is explicitly set to sandbox while agents.defaults.sandbox.mode=off. " +
"In this mode, exec runs directly on the gateway host." ,
remediation :
'Enable sandbox mode (`agents.defaults.sandbox.mode="non-main"` or `"all"`) or set tools.exec.host to "gateway" with approvals.' ,
} ) ;
}
const agents = Array . isArray ( cfg . agents ? . list ) ? cfg . agents . list : [ ] ;
const riskyAgents = agents
. filter (
( entry ) = >
entry &&
typeof entry === "object" &&
typeof entry . id === "string" &&
entry . tools ? . exec ? . host === "sandbox" &&
resolveSandboxConfigForAgent ( cfg , entry . id ) . mode === "off" ,
)
. map ( ( entry ) = > entry . id )
. slice ( 0 , 5 ) ;
if ( riskyAgents . length > 0 ) {
findings . push ( {
checkId : "tools.exec.host_sandbox_no_sandbox_agents" ,
severity : "warn" ,
title : "Agent exec host uses sandbox while sandbox mode is off" ,
detail :
` agents.list.*.tools.exec.host is set to sandbox for: ${ riskyAgents . join ( ", " ) } . ` +
"With sandbox mode off, exec runs directly on the gateway host." ,
remediation :
'Enable sandbox mode for these agents (`agents.list[].sandbox.mode`) or set their tools.exec.host to "gateway".' ,
} ) ;
}
2026-02-22 13:18:17 +01:00
const normalizeConfiguredSafeBins = ( entries : unknown ) : string [ ] = > {
if ( ! Array . isArray ( entries ) ) {
return [ ] ;
}
return Array . from (
new Set (
entries
. map ( ( entry ) = > ( typeof entry === "string" ? entry . trim ( ) . toLowerCase ( ) : "" ) )
. filter ( ( entry ) = > entry . length > 0 ) ,
) ,
) . toSorted ( ) ;
} ;
2026-02-24 23:29:12 +00:00
const normalizeConfiguredTrustedDirs = ( entries : unknown ) : string [ ] = > {
if ( ! Array . isArray ( entries ) ) {
return [ ] ;
}
return normalizeTrustedSafeBinDirs (
entries . filter ( ( entry ) : entry is string = > typeof entry === "string" ) ,
) ;
} ;
const classifyRiskySafeBinTrustedDir = ( entry : string ) : string | null = > {
const raw = entry . trim ( ) ;
if ( ! raw ) {
return null ;
}
if ( ! path . isAbsolute ( raw ) ) {
return "relative path (trust boundary depends on process cwd)" ;
}
const normalized = path . resolve ( raw ) . replace ( /\\/g , "/" ) . toLowerCase ( ) ;
if (
normalized === "/tmp" ||
normalized . startsWith ( "/tmp/" ) ||
normalized === "/var/tmp" ||
normalized . startsWith ( "/var/tmp/" ) ||
normalized === "/private/tmp" ||
normalized . startsWith ( "/private/tmp/" )
) {
return "temporary directory is mutable and easy to poison" ;
}
if (
normalized === "/usr/local/bin" ||
normalized === "/opt/homebrew/bin" ||
normalized === "/opt/local/bin" ||
normalized === "/home/linuxbrew/.linuxbrew/bin"
) {
return "package-manager bin directory (often user-writable)" ;
}
if (
normalized . startsWith ( "/users/" ) ||
normalized . startsWith ( "/home/" ) ||
normalized . includes ( "/.local/bin" )
) {
return "home-scoped bin directory (typically user-writable)" ;
}
if ( /^[a-z]:\/users\// . test ( normalized ) ) {
return "home-scoped bin directory (typically user-writable)" ;
}
return null ;
} ;
2026-02-22 13:18:17 +01:00
const globalExec = cfg . tools ? . exec ;
2026-02-24 23:29:12 +00:00
const riskyTrustedDirHits : string [ ] = [ ] ;
const collectRiskyTrustedDirHits = ( scopePath : string , entries : unknown ) : void = > {
for ( const entry of normalizeConfiguredTrustedDirs ( entries ) ) {
const reason = classifyRiskySafeBinTrustedDir ( entry ) ;
if ( ! reason ) {
continue ;
}
riskyTrustedDirHits . push ( ` - ${ scopePath } .safeBinTrustedDirs: ${ entry } ( ${ reason } ) ` ) ;
}
} ;
collectRiskyTrustedDirHits ( "tools.exec" , globalExec ? . safeBinTrustedDirs ) ;
for ( const entry of agents ) {
if ( ! entry || typeof entry !== "object" || typeof entry . id !== "string" ) {
continue ;
}
collectRiskyTrustedDirHits (
` agents.list. ${ entry . id } .tools.exec ` ,
entry . tools ? . exec ? . safeBinTrustedDirs ,
) ;
}
const interpreterHits : string [ ] = [ ] ;
2026-02-22 13:18:17 +01:00
const globalSafeBins = normalizeConfiguredSafeBins ( globalExec ? . safeBins ) ;
if ( globalSafeBins . length > 0 ) {
const merged = resolveMergedSafeBinProfileFixtures ( { global : globalExec } ) ? ? { } ;
const interpreters = listInterpreterLikeSafeBins ( globalSafeBins ) . filter ( ( bin ) = > ! merged [ bin ] ) ;
if ( interpreters . length > 0 ) {
interpreterHits . push ( ` - tools.exec.safeBins: ${ interpreters . join ( ", " ) } ` ) ;
}
}
for ( const entry of agents ) {
if ( ! entry || typeof entry !== "object" || typeof entry . id !== "string" ) {
continue ;
}
const agentExec = entry . tools ? . exec ;
const agentSafeBins = normalizeConfiguredSafeBins ( agentExec ? . safeBins ) ;
if ( agentSafeBins . length === 0 ) {
continue ;
}
const merged =
resolveMergedSafeBinProfileFixtures ( {
global : globalExec ,
local : agentExec ,
} ) ? ? { } ;
const interpreters = listInterpreterLikeSafeBins ( agentSafeBins ) . filter ( ( bin ) = > ! merged [ bin ] ) ;
if ( interpreters . length === 0 ) {
continue ;
}
interpreterHits . push (
` - agents.list. ${ entry . id } .tools.exec.safeBins: ${ interpreters . join ( ", " ) } ` ,
) ;
}
if ( interpreterHits . length > 0 ) {
findings . push ( {
checkId : "tools.exec.safe_bins_interpreter_unprofiled" ,
severity : "warn" ,
title : "safeBins includes interpreter/runtime binaries without explicit profiles" ,
detail :
` Detected interpreter-like safeBins entries missing explicit profiles: \ n ${ interpreterHits . join ( "\n" ) } \ n ` +
"These entries can turn safeBins into a broad execution surface when used with permissive argv profiles." ,
remediation :
"Remove interpreter/runtime bins from safeBins (prefer allowlist entries) or define hardened tools.exec.safeBinProfiles.<bin> rules." ,
} ) ;
}
2026-02-24 23:29:12 +00:00
if ( riskyTrustedDirHits . length > 0 ) {
findings . push ( {
checkId : "tools.exec.safe_bin_trusted_dirs_risky" ,
severity : "warn" ,
title : "safeBinTrustedDirs includes risky mutable directories" ,
detail :
` Detected risky safeBinTrustedDirs entries: \ n ${ riskyTrustedDirHits . slice ( 0 , 10 ) . join ( "\n" ) } ` +
( riskyTrustedDirHits . length > 10
? ` \ n- + ${ riskyTrustedDirHits . length - 10 } more entries. `
: "" ) ,
remediation :
"Prefer root-owned immutable bins, keep default trust dirs (/bin, /usr/bin), and avoid trusting temporary/home/package-manager paths unless tightly controlled." ,
} ) ;
}
2026-02-19 15:33:25 +01:00
return findings ;
}
2026-01-15 01:25:11 +00:00
async function maybeProbeGateway ( params : {
2026-01-30 03:15:10 +01:00
cfg : OpenClawConfig ;
2026-02-23 23:31:42 +00:00
env : NodeJS.ProcessEnv ;
2026-01-15 01:25:11 +00:00
timeoutMs : number ;
probe : typeof probeGateway ;
2026-03-05 12:53:56 -06:00
} ) : Promise < {
deep : SecurityAuditReport [ "deep" ] ;
authWarning? : string ;
} > {
2026-01-15 01:25:11 +00:00
const connection = buildGatewayConnectionDetails ( { config : params.cfg } ) ;
const url = connection . url ;
const isRemoteMode = params . cfg . gateway ? . mode === "remote" ;
const remoteUrlRaw =
typeof params . cfg . gateway ? . remote ? . url === "string" ? params . cfg . gateway . remote . url . trim ( ) : "" ;
const remoteUrlMissing = isRemoteMode && ! remoteUrlRaw ;
2026-03-05 12:53:56 -06:00
const authResolution =
2026-02-15 06:40:04 +00:00
! isRemoteMode || remoteUrlMissing
2026-03-05 12:53:56 -06:00
? resolveGatewayProbeAuthSafe ( { cfg : params.cfg , env : params.env , mode : "local" } )
: resolveGatewayProbeAuthSafe ( { cfg : params.cfg , env : params.env , mode : "remote" } ) ;
const res = await params
. probe ( { url , auth : authResolution.auth , timeoutMs : params.timeoutMs } )
. catch ( ( err ) = > ( {
ok : false ,
url ,
connectLatencyMs : null ,
error : String ( err ) ,
close : null ,
health : null ,
status : null ,
presence : null ,
configSnapshot : null ,
} ) ) ;
if ( authResolution . warning && ! res . ok ) {
res . error = res . error ? ` ${ res . error } ; ${ authResolution . warning } ` : authResolution . warning ;
}
2026-01-15 01:25:11 +00:00
return {
2026-03-05 12:53:56 -06:00
deep : {
gateway : {
attempted : true ,
url ,
ok : res.ok ,
error : res.ok ? null : res . error ,
close : res.close ? { code : res.close.code , reason : res.close.reason } : null ,
} ,
2026-01-15 01:25:11 +00:00
} ,
2026-03-05 12:53:56 -06:00
authWarning : authResolution.warning ,
2026-01-15 01:25:11 +00:00
} ;
}
2026-03-03 02:42:35 +00:00
async function createAuditExecutionContext (
opts : SecurityAuditOptions ,
) : Promise < AuditExecutionContext > {
2026-01-15 01:25:11 +00:00
const cfg = opts . config ;
2026-03-05 23:07:13 -06:00
const sourceConfig = opts . sourceConfig ? ? opts . config ;
2026-01-26 18:19:58 +00:00
const env = opts . env ? ? process . env ;
const platform = opts . platform ? ? process . platform ;
2026-03-03 02:42:35 +00:00
const includeFilesystem = opts . includeFilesystem !== false ;
const includeChannelSecurity = opts . includeChannelSecurity !== false ;
const deep = opts . deep === true ;
const deepTimeoutMs = Math . max ( 250 , opts . deepTimeoutMs ? ? 5000 ) ;
2026-01-15 05:31:35 +00:00
const stateDir = opts . stateDir ? ? resolveStateDir ( env ) ;
const configPath = opts . configPath ? ? resolveConfigPath ( env , stateDir ) ;
2026-03-03 02:42:35 +00:00
const configSnapshot = includeFilesystem
? opts . configSnapshot !== undefined
? opts . configSnapshot
: await readConfigSnapshotForAudit ( { env , configPath } ) . catch ( ( ) = > null )
: null ;
return {
cfg ,
2026-03-05 23:07:13 -06:00
sourceConfig ,
2026-03-03 02:42:35 +00:00
env ,
platform ,
includeFilesystem ,
includeChannelSecurity ,
deep ,
deepTimeoutMs ,
stateDir ,
configPath ,
execIcacls : opts.execIcacls ,
execDockerRawFn : opts.execDockerRawFn ,
probeGatewayFn : opts.probeGatewayFn ,
plugins : opts.plugins ,
configSnapshot ,
codeSafetySummaryCache : opts.codeSafetySummaryCache ? ? new Map < string , Promise < unknown > > ( ) ,
} ;
}
export async function runSecurityAudit ( opts : SecurityAuditOptions ) : Promise < SecurityAuditReport > {
const findings : SecurityAuditFinding [ ] = [ ] ;
const context = await createAuditExecutionContext ( opts ) ;
const { cfg , env , platform , stateDir , configPath } = context ;
2026-01-15 05:31:35 +00:00
findings . push ( . . . collectAttackSurfaceSummaryFindings ( cfg ) ) ;
findings . push ( . . . collectSyncedFolderFindings ( { stateDir , configPath } ) ) ;
2026-01-15 01:25:11 +00:00
2026-01-26 15:26:15 -08:00
findings . push ( . . . collectGatewayConfigFindings ( cfg , env ) ) ;
2026-02-13 02:01:57 +01:00
findings . push ( . . . collectBrowserControlFindings ( cfg , env ) ) ;
2026-01-15 01:25:11 +00:00
findings . push ( . . . collectLoggingFindings ( cfg ) ) ;
findings . push ( . . . collectElevatedFindings ( cfg ) ) ;
2026-02-19 15:33:25 +01:00
findings . push ( . . . collectExecRuntimeFindings ( cfg ) ) ;
2026-02-14 06:32:17 -05:00
findings . push ( . . . collectHooksHardeningFindings ( cfg , env ) ) ;
2026-02-19 14:25:45 +01:00
findings . push ( . . . collectGatewayHttpNoAuthFindings ( cfg , env ) ) ;
2026-02-14 06:32:17 -05:00
findings . push ( . . . collectGatewayHttpSessionKeyOverrideFindings ( cfg ) ) ;
2026-02-13 16:26:37 +01:00
findings . push ( . . . collectSandboxDockerNoopFindings ( cfg ) ) ;
2026-02-16 03:03:55 +01:00
findings . push ( . . . collectSandboxDangerousConfigFindings ( cfg ) ) ;
2026-02-13 16:26:37 +01:00
findings . push ( . . . collectNodeDenyCommandPatternFindings ( cfg ) ) ;
2026-02-22 08:44:12 +01:00
findings . push ( . . . collectNodeDangerousAllowCommandFindings ( cfg ) ) ;
2026-02-13 16:26:37 +01:00
findings . push ( . . . collectMinimalProfileOverrideFindings ( cfg ) ) ;
2026-01-15 05:31:35 +00:00
findings . push ( . . . collectSecretsInConfigFindings ( cfg ) ) ;
findings . push ( . . . collectModelHygieneFindings ( cfg ) ) ;
2026-01-20 23:45:50 +00:00
findings . push ( . . . collectSmallModelRiskFindings ( { cfg , env } ) ) ;
2026-01-15 05:31:35 +00:00
findings . push ( . . . collectExposureMatrixFindings ( cfg ) ) ;
2026-02-24 14:03:04 +00:00
findings . push ( . . . collectLikelyMultiUserSetupFindings ( cfg ) ) ;
2026-01-15 05:31:35 +00:00
2026-03-03 02:42:35 +00:00
if ( context . includeFilesystem ) {
2026-01-26 18:19:58 +00:00
findings . push (
. . . ( await collectFilesystemFindings ( {
stateDir ,
configPath ,
env ,
platform ,
2026-03-03 02:42:35 +00:00
execIcacls : context.execIcacls ,
2026-01-26 18:19:58 +00:00
} ) ) ,
) ;
2026-03-03 02:42:35 +00:00
if ( context . configSnapshot ) {
2026-01-26 18:19:58 +00:00
findings . push (
2026-03-03 02:42:35 +00:00
. . . ( await collectIncludeFilePermFindings ( {
configSnapshot : context.configSnapshot ,
env ,
platform ,
execIcacls : context.execIcacls ,
} ) ) ,
2026-01-26 18:19:58 +00:00
) ;
2026-01-15 05:31:35 +00:00
}
2026-01-26 18:19:58 +00:00
findings . push (
2026-03-03 02:42:35 +00:00
. . . ( await collectStateDeepFilesystemFindings ( {
cfg ,
env ,
stateDir ,
platform ,
execIcacls : context.execIcacls ,
} ) ) ,
2026-01-26 18:19:58 +00:00
) ;
2026-03-02 23:28:46 +00:00
findings . push ( . . . ( await collectWorkspaceSkillSymlinkEscapeFindings ( { cfg } ) ) ) ;
2026-02-21 13:25:35 +01:00
findings . push (
. . . ( await collectSandboxBrowserHashLabelFindings ( {
2026-03-03 02:42:35 +00:00
execDockerRawFn : context.execDockerRawFn ,
2026-02-21 13:25:35 +01:00
} ) ) ,
) ;
2026-01-15 05:31:35 +00:00
findings . push ( . . . ( await collectPluginsTrustFindings ( { cfg , stateDir } ) ) ) ;
2026-03-03 02:42:35 +00:00
if ( context . deep ) {
2026-03-02 21:07:34 +00:00
findings . push (
. . . ( await collectPluginsCodeSafetyFindings ( {
stateDir ,
2026-03-03 02:42:35 +00:00
summaryCache : context.codeSafetySummaryCache ,
2026-03-02 21:07:34 +00:00
} ) ) ,
) ;
findings . push (
. . . ( await collectInstalledSkillsCodeSafetyFindings ( {
cfg ,
stateDir ,
2026-03-03 02:42:35 +00:00
summaryCache : context.codeSafetySummaryCache ,
2026-03-02 21:07:34 +00:00
} ) ) ,
) ;
2026-02-05 17:06:11 -07:00
}
2026-01-15 01:25:11 +00:00
}
2026-03-03 02:42:35 +00:00
if ( context . includeChannelSecurity ) {
const plugins = context . plugins ? ? listChannelPlugins ( ) ;
2026-03-05 23:07:13 -06:00
findings . push (
. . . ( await collectChannelSecurityFindings ( {
cfg ,
sourceConfig : context.sourceConfig ,
plugins ,
} ) ) ,
) ;
2026-01-15 01:25:11 +00:00
}
2026-03-05 12:53:56 -06:00
const deepProbeResult = context . deep
2026-03-03 02:42:35 +00:00
? await maybeProbeGateway ( {
cfg ,
env ,
timeoutMs : context.deepTimeoutMs ,
probe : context.probeGatewayFn ? ? probeGateway ,
} )
: undefined ;
2026-03-05 12:53:56 -06:00
const deep = deepProbeResult ? . deep ;
2026-01-15 01:25:11 +00:00
2026-01-31 16:03:28 +09:00
if ( deep ? . gateway ? . attempted && ! deep . gateway . ok ) {
2026-01-15 01:25:11 +00:00
findings . push ( {
checkId : "gateway.probe_failed" ,
severity : "warn" ,
title : "Gateway probe failed (deep)" ,
detail : deep.gateway.error ? ? "gateway unreachable" ,
2026-01-30 03:15:10 +01:00
remediation : ` Run " ${ formatCliCommand ( "openclaw status --all" ) } " to debug connectivity/auth, then re-run " ${ formatCliCommand ( "openclaw security audit --deep" ) } ". ` ,
2026-01-15 01:25:11 +00:00
} ) ;
}
2026-03-05 12:53:56 -06:00
if ( deepProbeResult ? . authWarning ) {
findings . push ( {
checkId : "gateway.probe_auth_secretref_unavailable" ,
severity : "warn" ,
title : "Gateway probe auth SecretRef is unavailable" ,
detail : deepProbeResult.authWarning ,
remediation : ` Set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD in this shell or resolve the external secret provider, then re-run " ${ formatCliCommand ( "openclaw security audit --deep" ) } ". ` ,
} ) ;
}
2026-01-15 01:25:11 +00:00
const summary = countBySeverity ( findings ) ;
return { ts : Date.now ( ) , summary , findings , deep } ;
}