2026-01-30 03:15:10 +01:00
import type { OpenClawConfig } from "../config/config.js" ;
2026-02-01 10:03:47 +09:00
import type { ExecFn } from "./windows-acl.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-01-15 04:49:37 +00:00
import { resolveConfigPath , resolveStateDir } from "../config/paths.js" ;
2026-01-15 01:25:11 +00:00
import { resolveGatewayAuth } from "../gateway/auth.js" ;
import { buildGatewayConnectionDetails } from "../gateway/call.js" ;
2026-02-15 06:40:04 +00:00
import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js" ;
2026-01-15 01:25:11 +00:00
import { probeGateway } from "../gateway/probe.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-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-13 16:26:37 +01:00
collectMinimalProfileOverrideFindings ,
2026-01-15 05:31:35 +00:00
collectModelHygieneFindings ,
2026-02-13 16:26:37 +01:00
collectNodeDenyCommandPatternFindings ,
2026-01-20 23:45:50 +00:00
collectSmallModelRiskFindings ,
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 ,
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-14 13:25:28 +01:00
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.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-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-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 ) ;
}
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 ) {
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-01-26 18:19:58 +00:00
} else if ( 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-01-26 18:19:58 +00:00
} else if ( 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 ;
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 ;
const hasSharedSecret =
( auth . mode === "token" && hasToken ) || ( auth . mode === "password" && hasPassword ) ;
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-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-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-01-26 17:40:24 +00:00
severity : "critical" ,
2026-01-21 23:58:30 +00:00
title : "Control UI allows insecure HTTP auth" ,
detail :
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity." ,
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-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-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 ) ;
if ( ! browserAuth . token && ! browserAuth . password ) {
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 ;
}
async function maybeProbeGateway ( params : {
2026-01-30 03:15:10 +01:00
cfg : OpenClawConfig ;
2026-01-15 01:25:11 +00:00
timeoutMs : number ;
probe : typeof probeGateway ;
} ) : Promise < SecurityAuditReport [ "deep" ] > {
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-02-15 06:40:04 +00:00
const auth =
! isRemoteMode || remoteUrlMissing
? resolveGatewayProbeAuth ( { cfg : params.cfg , mode : "local" } )
: resolveGatewayProbeAuth ( { cfg : params.cfg , mode : "remote" } ) ;
2026-01-15 01:25:11 +00:00
const res = await params . probe ( { url , auth , timeoutMs : params.timeoutMs } ) . catch ( ( err ) = > ( {
ok : false ,
url ,
connectLatencyMs : null ,
error : String ( err ) ,
close : null ,
health : null ,
status : null ,
presence : null ,
configSnapshot : null ,
} ) ) ;
return {
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 ,
} ,
} ;
}
export async function runSecurityAudit ( opts : SecurityAuditOptions ) : Promise < SecurityAuditReport > {
const findings : SecurityAuditFinding [ ] = [ ] ;
const cfg = opts . config ;
2026-01-26 18:19:58 +00:00
const env = opts . env ? ? process . env ;
const platform = opts . platform ? ? process . platform ;
const execIcacls = opts . execIcacls ;
2026-01-15 05:31:35 +00:00
const stateDir = opts . stateDir ? ? resolveStateDir ( env ) ;
const configPath = opts . configPath ? ? resolveConfigPath ( env , stateDir ) ;
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-14 06:32:17 -05:00
findings . push ( . . . collectHooksHardeningFindings ( cfg , env ) ) ;
findings . push ( . . . collectGatewayHttpSessionKeyOverrideFindings ( cfg ) ) ;
2026-02-13 16:26:37 +01:00
findings . push ( . . . collectSandboxDockerNoopFindings ( cfg ) ) ;
findings . push ( . . . collectNodeDenyCommandPatternFindings ( cfg ) ) ;
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 ) ) ;
const configSnapshot =
opts . includeFilesystem !== false
? await readConfigSnapshotForAudit ( { env , configPath } ) . catch ( ( ) = > null )
: null ;
2026-01-15 01:25:11 +00:00
if ( opts . includeFilesystem !== false ) {
2026-01-26 18:19:58 +00:00
findings . push (
. . . ( await collectFilesystemFindings ( {
stateDir ,
configPath ,
env ,
platform ,
execIcacls ,
} ) ) ,
) ;
2026-01-15 05:31:35 +00:00
if ( configSnapshot ) {
2026-01-26 18:19:58 +00:00
findings . push (
. . . ( await collectIncludeFilePermFindings ( { configSnapshot , env , platform , execIcacls } ) ) ,
) ;
2026-01-15 05:31:35 +00:00
}
2026-01-26 18:19:58 +00:00
findings . push (
. . . ( await collectStateDeepFilesystemFindings ( { cfg , env , stateDir , platform , execIcacls } ) ) ,
) ;
2026-01-15 05:31:35 +00:00
findings . push ( . . . ( await collectPluginsTrustFindings ( { cfg , stateDir } ) ) ) ;
2026-02-05 17:06:11 -07:00
if ( opts . deep === true ) {
findings . push ( . . . ( await collectPluginsCodeSafetyFindings ( { stateDir } ) ) ) ;
findings . push ( . . . ( await collectInstalledSkillsCodeSafetyFindings ( { cfg , stateDir } ) ) ) ;
}
2026-01-15 01:25:11 +00:00
}
if ( opts . includeChannelSecurity !== false ) {
const plugins = opts . plugins ? ? listChannelPlugins ( ) ;
findings . push ( . . . ( await collectChannelSecurityFindings ( { cfg , plugins } ) ) ) ;
}
const deep =
opts . deep === true
? await maybeProbeGateway ( {
cfg ,
timeoutMs : Math.max ( 250 , opts . deepTimeoutMs ? ? 5000 ) ,
probe : opts.probeGatewayFn ? ? probeGateway ,
} )
: undefined ;
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
} ) ;
}
const summary = countBySeverity ( findings ) ;
return { ts : Date.now ( ) , summary , findings , deep } ;
}