diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcedbe65b4..884747c779e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(infra): expand host env security policy denylist [AI]. (#63277) Thanks @pgondhi987. - fix(agents): guard nodes tool outPath against workspace boundary [AI-assisted]. (#63551) Thanks @pgondhi987. - fix(qqbot): enforce media storage boundary for all outbound local file paths [AI]. (#63271) Thanks @pgondhi987. - iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana. diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index a3d92efa3f1..c431028abfc 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -8,6 +8,8 @@ struct HostEnvOverrideDiagnostics: Equatable { enum HostEnvSanitizer { /// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs. /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. + private static let blockedInheritedKeys = HostEnvSecurityPolicy.blockedInheritedKeys + private static let blockedInheritedPrefixes = HostEnvSecurityPolicy.blockedInheritedPrefixes private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys @@ -28,6 +30,11 @@ enum HostEnvSanitizer { return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) } + private static func isBlockedInherited(_ upperKey: String) -> Bool { + if self.blockedInheritedKeys.contains(upperKey) { return true } + return self.blockedInheritedPrefixes.contains(where: { upperKey.hasPrefix($0) }) + } + private static func isBlockedOverride(_ upperKey: String) -> Bool { if self.blockedOverrideKeys.contains(upperKey) { return true } return self.blockedOverridePrefixes.contains(where: { upperKey.hasPrefix($0) }) @@ -113,7 +120,7 @@ enum HostEnvSanitizer { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } let upper = key.uppercased() - if self.isBlocked(upper) { continue } + if self.isBlockedInherited(upper) { continue } merged[key] = value } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 8e9be035a08..d66b3f0920c 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -5,20 +5,232 @@ import Foundation enum HostEnvSecurityPolicy { + static let blockedInheritedKeys: Set = [ + "_JAVA_OPTIONS", + "AMQP_URL", + "ANSIBLE_CALLBACK_PLUGINS", + "ANSIBLE_COLLECTIONS_PATH", + "ANSIBLE_CONFIG", + "ANSIBLE_CONNECTION_PLUGINS", + "ANSIBLE_FILTER_PLUGINS", + "ANSIBLE_INVENTORY_PLUGINS", + "ANSIBLE_LIBRARY", + "ANSIBLE_LOOKUP_PLUGINS", + "ANSIBLE_MODULE_UTILS", + "ANSIBLE_REMOTE_TEMP", + "ANSIBLE_ROLES_PATH", + "ANSIBLE_STRATEGY_PLUGINS", + "ANT_OPTS", + "AWS_ACCESS_KEY_ID", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_SECRET_ACCESS_KEY", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "BASH_ENV", + "BROWSER", + "BUN_CONFIG_REGISTRY", + "BUNDLE_GEMFILE", + "BZR_EDITOR", + "BZR_PLUGIN_PATH", + "BZR_SSH", + "C_INCLUDE_PATH", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_HOME", + "CATALINA_OPTS", + "CC", + "CFLAGS", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "CLASSPATH", + "CMAKE_C_COMPILER", + "CMAKE_CXX_COMPILER", + "CMAKE_TOOLCHAIN_FILE", + "COMPOSER_HOME", + "CONFIG_SHELL", + "CONFIG_SITE", + "CORECLR_PROFILER", + "CORECLR_PROFILER_PATH", + "CPATH", + "CPLUS_INCLUDE_PATH", + "CURL_HOME", + "CXX", + "DATABASE_URL", + "DENO_DIR", + "DOTNET_ADDITIONAL_DEPS", + "DOTNET_STARTUP_HOOKS", + "EDITOR", + "ELIXIR_ERL_OPTIONS", + "EMACSLOADPATH", + "ENV", + "ERL_AFLAGS", + "ERL_FLAGS", + "ERL_ZFLAGS", + "EXINIT", + "FCEDIT", + "GCONV_PATH", + "GEM_HOME", + "GEM_PATH", + "GH_TOKEN", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_ASKPASS", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_HOOK_PATH", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_PROXY_COMMAND", + "GIT_SEQUENCE_EDITOR", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GIT_TEMPLATE_DIR", + "GIT_WORK_TREE", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "GLIBC_TUNABLES", + "GOENV", + "GOFLAGS", + "GONOPROXY", + "GONOSUMCHECK", + "GONOSUMDB", + "GOPATH", + "GOPRIVATE", + "GOPROXY", + "GRADLE_OPTS", + "GVIMINIT", + "HELM_HOME", + "HELM_PLUGINS", + "HGRCPATH", + "HOSTALIASES", + "IFS", + "JAVA_OPTS", + "JAVA_TOOL_OPTIONS", + "JDK_JAVA_OPTIONS", + "JULIA_EDITOR", + "LDFLAGS", + "LESSCLOSE", + "LESSOPEN", + "LIBRARY_PATH", + "LUA_CPATH", + "LUA_INIT", + "LUA_INIT_5_1", + "LUA_INIT_5_2", + "LUA_INIT_5_3", + "LUA_INIT_5_4", + "LUA_PATH", + "MAKEFLAGS", + "MAVEN_OPTS", + "MFLAGS", + "MONGODB_URI", + "MYVIMRC", + "NODE_AUTH_TOKEN", + "NODE_OPTIONS", + "NODE_PATH", + "NPM_TOKEN", + "OBJC_INCLUDE_PATH", + "OPENSSL_CONF", + "OPENSSL_ENGINES", + "PACKER_PLUGIN_PATH", + "PERL5DB", + "PERL5DBCMD", + "PERL5LIB", + "PERL5OPT", + "PHP_INI_SCAN_DIR", + "PHPRC", + "PIP_CONFIG_FILE", + "PIP_EXTRA_INDEX_URL", + "PIP_FIND_LINKS", + "PIP_INDEX_URL", + "PIP_PYPI_URL", + "PIP_TRUSTED_HOST", + "PROMPT_COMMAND", + "PS4", + "PYTHONBREAKPOINT", + "PYTHONHOME", + "PYTHONPATH", + "PYTHONSTARTUP", + "PYTHONUSERBASE", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_LIBS_USER", + "R_PROFILE", + "R_PROFILE_USER", + "REDIS_URL", + "RUBYLIB", + "RUBYOPT", + "RUBYSHELL", + "RUSTC_WRAPPER", + "RUSTFLAGS", + "SBT_OPTS", + "SHELL", + "SHELLOPTS", + "SSH_ASKPASS", + "SSLKEYLOGFILE", + "SUDO_ASKPASS", + "SUDO_EDITOR", + "SVN_EDITOR", + "SVN_SSH", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", + "UV_DEFAULT_INDEX", + "UV_EXTRA_INDEX_URL", + "UV_INDEX", + "UV_INDEX_URL", + "UV_PYTHON", + "VAGRANT_VAGRANTFILE", + "VIMINIT", + "VIRTUAL_ENV", + "VISUAL", + "WGETRC", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "YARN_RC_FILENAME" + ] + + static let blockedInheritedPrefixes: [String] = [ + "BASH_FUNC_", + "DYLD_", + "LD_" + ] + static let blockedKeys: Set = [ "_JAVA_OPTIONS", "ANT_OPTS", "BASH_ENV", "BROWSER", + "BZR_EDITOR", + "BZR_PLUGIN_PATH", + "BZR_SSH", "CARGO_BUILD_RUSTC", "CARGO_BUILD_RUSTC_WRAPPER", + "CATALINA_OPTS", "CC", "CMAKE_C_COMPILER", "CMAKE_CXX_COMPILER", + "CMAKE_TOOLCHAIN_FILE", + "CONFIG_SHELL", + "CONFIG_SITE", + "CORECLR_PROFILER", "CXX", "DOTNET_ADDITIONAL_DEPS", "DOTNET_STARTUP_HOOKS", + "ELIXIR_ERL_OPTIONS", + "EMACSLOADPATH", "ENV", + "ERL_AFLAGS", + "ERL_FLAGS", + "ERL_ZFLAGS", + "EXINIT", "GCONV_PATH", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", @@ -26,6 +238,7 @@ enum HostEnvSecurityPolicy { "GIT_EDITOR", "GIT_EXEC_PATH", "GIT_EXTERNAL_DIFF", + "GIT_HOOK_PATH", "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", @@ -37,42 +250,85 @@ enum HostEnvSecurityPolicy { "GIT_WORK_TREE", "GLIBC_TUNABLES", "GRADLE_OPTS", + "GVIMINIT", + "HELM_PLUGINS", "HGRCPATH", + "HOSTALIASES", "IFS", "JAVA_OPTS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", + "JULIA_EDITOR", + "LUA_INIT", + "LUA_INIT_5_1", + "LUA_INIT_5_2", + "LUA_INIT_5_3", + "LUA_INIT_5_4", "MAKEFLAGS", "MAVEN_OPTS", "MFLAGS", + "MYVIMRC", "NODE_OPTIONS", "NODE_PATH", + "PACKER_PLUGIN_PATH", "PERL5LIB", "PERL5OPT", "PS4", "PYTHONBREAKPOINT", "PYTHONHOME", "PYTHONPATH", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_PROFILE", + "R_PROFILE_USER", "RUBYLIB", "RUBYOPT", + "RUBYSHELL", "RUSTC_WRAPPER", "SBT_OPTS", "SHELL", "SHELLOPTS", - "SSLKEYLOGFILE" + "SSLKEYLOGFILE", + "SUDO_ASKPASS", + "SVN_EDITOR", + "SVN_SSH", + "VAGRANT_VAGRANTFILE", + "VIMINIT" ] static let blockedOverrideKeys: Set = [ "ALL_PROXY", + "AMQP_URL", + "ANSIBLE_CALLBACK_PLUGINS", + "ANSIBLE_COLLECTIONS_PATH", + "ANSIBLE_CONFIG", + "ANSIBLE_CONNECTION_PLUGINS", + "ANSIBLE_FILTER_PLUGINS", + "ANSIBLE_INVENTORY_PLUGINS", + "ANSIBLE_LIBRARY", + "ANSIBLE_LOOKUP_PLUGINS", + "ANSIBLE_MODULE_UTILS", + "ANSIBLE_REMOTE_TEMP", + "ANSIBLE_ROLES_PATH", + "ANSIBLE_STRATEGY_PLUGINS", + "AWS_ACCESS_KEY_ID", "AWS_CONFIG_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_SECRET_ACCESS_KEY", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_AUTH_LOCATION", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", "BUN_CONFIG_REGISTRY", "BUNDLE_GEMFILE", "C_INCLUDE_PATH", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_HOME", + "CFLAGS", "CGO_CFLAGS", "CGO_LDFLAGS", "CLASSPATH", @@ -82,6 +338,7 @@ enum HostEnvSecurityPolicy { "CPLUS_INCLUDE_PATH", "CURL_CA_BUNDLE", "CURL_HOME", + "DATABASE_URL", "DENO_DIR", "DOCKER_CERT_PATH", "DOCKER_CONTEXT", @@ -91,6 +348,7 @@ enum HostEnvSecurityPolicy { "FCEDIT", "GEM_HOME", "GEM_PATH", + "GH_TOKEN", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -106,6 +364,8 @@ enum HostEnvSecurityPolicy { "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GIT_WORK_TREE", + "GITHUB_TOKEN", + "GITLAB_TOKEN", "GOENV", "GOFLAGS", "GONOPROXY", @@ -123,6 +383,7 @@ enum HostEnvSecurityPolicy { "HTTP_PROXY", "HTTPS_PROXY", "KUBECONFIG", + "LDFLAGS", "LESSCLOSE", "LESSOPEN", "LIBRARY_PATH", @@ -131,9 +392,12 @@ enum HostEnvSecurityPolicy { "MAKEFLAGS", "MANPAGER", "MFLAGS", + "MONGODB_URI", "NO_PROXY", + "NODE_AUTH_TOKEN", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", + "NPM_TOKEN", "OBJC_INCLUDE_PATH", "OPENSSL_CONF", "OPENSSL_ENGINES", @@ -151,13 +415,18 @@ enum HostEnvSecurityPolicy { "PROMPT_COMMAND", "PYTHONSTARTUP", "PYTHONUSERBASE", + "R_LIBS_USER", + "REDIS_URL", "REQUESTS_CA_BUNDLE", "RUSTC_WRAPPER", "RUSTFLAGS", "SSH_ASKPASS", + "SSH_AUTH_SOCK", "SSL_CERT_DIR", "SSL_CERT_FILE", "SUDO_EDITOR", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_INDEX", @@ -166,6 +435,7 @@ enum HostEnvSecurityPolicy { "VIRTUAL_ENV", "VISUAL", "WGETRC", + "XDG_CONFIG_DIRS", "XDG_CONFIG_HOME", "YARN_RC_FILENAME", "ZDOTDIR" @@ -174,7 +444,8 @@ enum HostEnvSecurityPolicy { static let blockedOverridePrefixes: [String] = [ "CARGO_REGISTRIES_", "GIT_CONFIG_", - "NPM_CONFIG_" + "NPM_CONFIG_", + "TF_VAR_" ] static let blockedPrefixes: [String] = [ diff --git a/scripts/generate-host-env-security-policy-swift.mjs b/scripts/generate-host-env-security-policy-swift.mjs index e31c2b83838..e80e2ef2a47 100644 --- a/scripts/generate-host-env-security-policy-swift.mjs +++ b/scripts/generate-host-env-security-policy-swift.mjs @@ -37,6 +37,14 @@ const generated = `// Generated file. Do not edit directly. import Foundation enum HostEnvSecurityPolicy { + static let blockedInheritedKeys: Set = [ +${renderSwiftStringArray(policy.blockedInheritedKeys)} + ] + + static let blockedInheritedPrefixes: [String] = [ +${renderSwiftStringArray(policy.blockedInheritedPrefixes)} + ] + static let blockedKeys: Set = [ ${renderSwiftStringArray(policy.blockedKeys)} ] diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 094cceb1154..d4b9a1178d5 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -9,7 +9,7 @@ import { type ExecTarget, } from "../infra/exec-approvals.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; -import { isDangerousHostEnvVarName } from "../infra/host-env-security.js"; +import { isDangerousHostInheritedEnvVarName } from "../infra/host-env-security.js"; import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { scopedHeartbeatWakeOptions } from "../routing/session-key.js"; @@ -72,7 +72,7 @@ export function sanitizeHostBaseEnv(env: Record): Record): void { const upperKey = key.toUpperCase(); // 1. Block known dangerous variables (Fail Closed) - if (isDangerousHostEnvVarName(upperKey)) { + if (isDangerousHostInheritedEnvVarName(upperKey)) { throw new Error( `Security Violation: Environment variable '${key}' is forbidden during host execution.`, ); diff --git a/src/infra/host-env-security-policy.d.ts b/src/infra/host-env-security-policy.d.ts index f9b18d43a70..f87fbd0694a 100644 --- a/src/infra/host-env-security-policy.d.ts +++ b/src/infra/host-env-security-policy.d.ts @@ -1,6 +1,9 @@ export type HostEnvSecurityPolicy = Readonly<{ blockedEverywhereKeys: readonly string[]; blockedOverrideOnlyKeys: readonly string[]; + allowedInheritedOverrideOnlyKeys: readonly string[]; + blockedInheritedKeys: readonly string[]; + blockedInheritedPrefixes: readonly string[]; blockedPrefixes: readonly string[]; blockedOverridePrefixes: readonly string[]; blockedKeys: readonly string[]; diff --git a/src/infra/host-env-security-policy.js b/src/infra/host-env-security-policy.js index cd2dadd7c86..0e455048843 100644 --- a/src/infra/host-env-security-policy.js +++ b/src/infra/host-env-security-policy.js @@ -11,12 +11,26 @@ function sortUniqueUppercase(values) { function derivePolicyArrays(policy) { const blockedEverywhereKeys = policy.blockedEverywhereKeys ?? []; const blockedOverrideOnlyKeys = policy.blockedOverrideOnlyKeys ?? []; + const allowedInheritedOverrideOnlyKeys = policy.allowedInheritedOverrideOnlyKeys ?? []; + const allowedInheritedOverrideOnlyUpper = new Set( + allowedInheritedOverrideOnlyKeys.map((value) => value.toUpperCase()), + ); + const blockedPrefixes = policy.blockedPrefixes ?? []; + const blockedOverridePrefixes = policy.blockedOverridePrefixes ?? []; + const blockedInheritedPrefixes = policy.blockedInheritedPrefixes ?? blockedPrefixes; return { + blockedInheritedKeys: sortUniqueUppercase([ + ...blockedEverywhereKeys, + ...blockedOverrideOnlyKeys.filter( + (value) => !allowedInheritedOverrideOnlyUpper.has(value.toUpperCase()), + ), + ]), + blockedInheritedPrefixes: sortUniqueUppercase(blockedInheritedPrefixes), blockedKeys: sortUniqueUppercase(blockedEverywhereKeys), blockedOverrideKeys: sortUniqueUppercase(blockedOverrideOnlyKeys), - blockedPrefixes: sortUniqueUppercase(policy.blockedPrefixes ?? []), - blockedOverridePrefixes: sortUniqueUppercase(policy.blockedOverridePrefixes ?? []), + blockedPrefixes: sortUniqueUppercase(blockedPrefixes), + blockedOverridePrefixes: sortUniqueUppercase(blockedOverridePrefixes), }; } @@ -25,6 +39,11 @@ export function loadHostEnvSecurityPolicy(rawPolicy = HOST_ENV_SECURITY_POLICY_J return Object.freeze({ blockedEverywhereKeys: Object.freeze(rawPolicy.blockedEverywhereKeys ?? []), blockedOverrideOnlyKeys: Object.freeze(rawPolicy.blockedOverrideOnlyKeys ?? []), + allowedInheritedOverrideOnlyKeys: Object.freeze( + rawPolicy.allowedInheritedOverrideOnlyKeys ?? [], + ), + blockedInheritedKeys: derived.blockedInheritedKeys, + blockedInheritedPrefixes: derived.blockedInheritedPrefixes, blockedPrefixes: derived.blockedPrefixes, blockedOverridePrefixes: derived.blockedOverridePrefixes, blockedKeys: derived.blockedKeys, diff --git a/src/infra/host-env-security-policy.json b/src/infra/host-env-security-policy.json index 9ead4b98220..fdfc7ebe4fe 100644 --- a/src/infra/host-env-security-policy.json +++ b/src/infra/host-env-security-policy.json @@ -53,7 +53,43 @@ "SBT_OPTS", "GRADLE_OPTS", "ANT_OPTS", - "HGRCPATH" + "HGRCPATH", + "EXINIT", + "VIMINIT", + "MYVIMRC", + "GVIMINIT", + "LUA_INIT", + "LUA_INIT_5_1", + "LUA_INIT_5_2", + "LUA_INIT_5_3", + "LUA_INIT_5_4", + "EMACSLOADPATH", + "RUBYSHELL", + "GIT_HOOK_PATH", + "SVN_EDITOR", + "SVN_SSH", + "BZR_EDITOR", + "BZR_SSH", + "BZR_PLUGIN_PATH", + "SUDO_ASKPASS", + "JULIA_EDITOR", + "CONFIG_SITE", + "CONFIG_SHELL", + "CMAKE_TOOLCHAIN_FILE", + "CATALINA_OPTS", + "CORECLR_PROFILER", + "HELM_PLUGINS", + "PACKER_PLUGIN_PATH", + "VAGRANT_VAGRANTFILE", + "ERL_AFLAGS", + "ERL_FLAGS", + "ERL_ZFLAGS", + "ELIXIR_ERL_OPTIONS", + "R_ENVIRON", + "R_PROFILE", + "R_ENVIRON_USER", + "R_PROFILE_USER", + "HOSTALIASES" ], "blockedOverrideOnlyKeys": [ "HOME", @@ -93,6 +129,7 @@ "WGETRC", "CURL_HOME", "CLASSPATH", + "CFLAGS", "CGO_CFLAGS", "CGO_LDFLAGS", "GOFLAGS", @@ -130,15 +167,11 @@ "UV_DEFAULT_INDEX", "DOCKER_CONTEXT", "LIBRARY_PATH", + "LDFLAGS", "CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "OBJC_INCLUDE_PATH", - "NODE_EXTRA_CA_CERTS", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", "GOPROXY", "GONOSUMCHECK", "GONOSUMDB", @@ -160,15 +193,78 @@ "COMPOSER_HOME", "CARGO_BUILD_RUSTC_WRAPPER", "XDG_CONFIG_HOME", + "XDG_CONFIG_DIRS", "AWS_CONFIG_FILE", "KUBECONFIG", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_AUTH_LOCATION", - "CARGO_HOME", - "HELM_HOME" + "HELM_HOME", + "ANSIBLE_CONFIG", + "ANSIBLE_LIBRARY", + "ANSIBLE_CALLBACK_PLUGINS", + "ANSIBLE_COLLECTIONS_PATH", + "ANSIBLE_CONNECTION_PLUGINS", + "ANSIBLE_FILTER_PLUGINS", + "ANSIBLE_INVENTORY_PLUGINS", + "ANSIBLE_LOOKUP_PLUGINS", + "ANSIBLE_MODULE_UTILS", + "ANSIBLE_REMOTE_TEMP", + "ANSIBLE_ROLES_PATH", + "ANSIBLE_STRATEGY_PLUGINS", + "R_LIBS_USER", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", + "AMQP_URL", + "AWS_ACCESS_KEY_ID", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_SECRET_ACCESS_KEY", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "DATABASE_URL", + "GH_TOKEN", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "MONGODB_URI", + "NODE_AUTH_TOKEN", + "NPM_TOKEN", + "REDIS_URL", + "SSH_AUTH_SOCK" ], - "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_", "CARGO_REGISTRIES_"], + "allowedInheritedOverrideOnlyKeys": [ + "ALL_PROXY", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AZURE_AUTH_LOCATION", + "CURL_CA_BUNDLE", + "DOCKER_CERT_PATH", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "GIT_PAGER", + "GOOGLE_APPLICATION_CREDENTIALS", + "GRADLE_USER_HOME", + "HISTFILE", + "HOME", + "HTTPS_PROXY", + "HTTP_PROXY", + "KUBECONFIG", + "MANPAGER", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NO_PROXY", + "PAGER", + "REQUESTS_CA_BUNDLE", + "SSH_AUTH_SOCK", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "ZDOTDIR" + ], + "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_", "CARGO_REGISTRIES_", "TF_VAR_"], "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"] } diff --git a/src/infra/host-env-security.policy-parity.test.ts b/src/infra/host-env-security.policy-parity.test.ts index 295d1c5e1fe..d3610e2981e 100644 --- a/src/infra/host-env-security.policy-parity.test.ts +++ b/src/infra/host-env-security.policy-parity.test.ts @@ -36,6 +36,14 @@ describe("host env security policy parity", () => { const sanitizerSource = fs.readFileSync(sanitizerSwiftPath, "utf8"); const swiftBlockedKeys = parseSwiftStringArray(generatedSource, "static let blockedKeys"); + const swiftBlockedInheritedKeys = parseSwiftStringArray( + generatedSource, + "static let blockedInheritedKeys", + ); + const swiftBlockedInheritedPrefixes = parseSwiftStringArray( + generatedSource, + "static let blockedInheritedPrefixes", + ); const swiftBlockedOverrideKeys = parseSwiftStringArray( generatedSource, "static let blockedOverrideKeys", @@ -49,11 +57,19 @@ describe("host env security policy parity", () => { "static let blockedPrefixes", ); + expect(swiftBlockedInheritedKeys).toEqual(policy.blockedInheritedKeys); + expect(swiftBlockedInheritedPrefixes).toEqual(policy.blockedInheritedPrefixes ?? []); expect(swiftBlockedKeys).toEqual(policy.blockedKeys); expect(swiftBlockedOverrideKeys).toEqual(policy.blockedOverrideKeys ?? []); expect(swiftBlockedOverridePrefixes).toEqual(policy.blockedOverridePrefixes ?? []); expect(swiftBlockedPrefixes).toEqual(policy.blockedPrefixes); + expect(sanitizerSource).toContain( + "private static let blockedInheritedKeys = HostEnvSecurityPolicy.blockedInheritedKeys", + ); + expect(sanitizerSource).toContain( + "private static let blockedInheritedPrefixes = HostEnvSecurityPolicy.blockedInheritedPrefixes", + ); expect(sanitizerSource).toContain( "private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys", ); @@ -73,8 +89,24 @@ describe("host env security policy parity", () => { const policyPath = path.join(repoRoot, "src/infra/host-env-security-policy.json"); const rawPolicy = JSON.parse(fs.readFileSync(policyPath, "utf8")); const policy = loadHostEnvSecurityPolicy(rawPolicy); + const allowedInheritedOverrideOnlyKeys = new Set( + (rawPolicy.allowedInheritedOverrideOnlyKeys ?? []).map((value: string) => + value.toUpperCase(), + ), + ); expect(policy.blockedKeys).toEqual(sortUnique([...policy.blockedEverywhereKeys])); expect(policy.blockedOverrideKeys).toEqual(sortUnique([...policy.blockedOverrideOnlyKeys])); + expect(policy.blockedInheritedKeys).toEqual( + sortUnique([ + ...policy.blockedEverywhereKeys, + ...policy.blockedOverrideOnlyKeys.filter( + (value) => !allowedInheritedOverrideOnlyKeys.has(value.toUpperCase()), + ), + ]), + ); + expect(policy.blockedInheritedPrefixes).toEqual( + sortUnique(rawPolicy.blockedInheritedPrefixes ?? rawPolicy.blockedPrefixes ?? []), + ); }); }); diff --git a/src/infra/host-env-security.reported-baseline.json b/src/infra/host-env-security.reported-baseline.json new file mode 100644 index 00000000000..6d877e9bb74 --- /dev/null +++ b/src/infra/host-env-security.reported-baseline.json @@ -0,0 +1,241 @@ +{ + "source": "OpenClaw host env dangerous-variable baseline (reported GHSA class)", + "generatedAt": "2026-04-10", + "reportedDangerousEverywhereKeys": [ + "_JAVA_OPTIONS", + "ANT_OPTS", + "BASH_ENV", + "BROWSER", + "BZR_EDITOR", + "BZR_PLUGIN_PATH", + "BZR_SSH", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CATALINA_OPTS", + "CC", + "CMAKE_C_COMPILER", + "CMAKE_CXX_COMPILER", + "CMAKE_TOOLCHAIN_FILE", + "CONFIG_SHELL", + "CONFIG_SITE", + "CORECLR_PROFILER", + "CXX", + "DOTNET_ADDITIONAL_DEPS", + "DOTNET_STARTUP_HOOKS", + "ELIXIR_ERL_OPTIONS", + "EMACSLOADPATH", + "ENV", + "ERL_AFLAGS", + "ERL_FLAGS", + "ERL_ZFLAGS", + "EXINIT", + "GCONV_PATH", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_EDITOR", + "GIT_EXEC_PATH", + "GIT_EXTERNAL_DIFF", + "GIT_HOOK_PATH", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_SEQUENCE_EDITOR", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GIT_TEMPLATE_DIR", + "GIT_WORK_TREE", + "GLIBC_TUNABLES", + "GRADLE_OPTS", + "GVIMINIT", + "HELM_PLUGINS", + "HGRCPATH", + "HOSTALIASES", + "IFS", + "JAVA_OPTS", + "JAVA_TOOL_OPTIONS", + "JDK_JAVA_OPTIONS", + "JULIA_EDITOR", + "LUA_INIT", + "LUA_INIT_5_1", + "LUA_INIT_5_2", + "LUA_INIT_5_3", + "LUA_INIT_5_4", + "MAKEFLAGS", + "MAVEN_OPTS", + "MFLAGS", + "MYVIMRC", + "NODE_OPTIONS", + "NODE_PATH", + "PACKER_PLUGIN_PATH", + "PERL5LIB", + "PERL5OPT", + "PS4", + "PYTHONBREAKPOINT", + "PYTHONHOME", + "PYTHONPATH", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_PROFILE", + "R_PROFILE_USER", + "RUBYLIB", + "RUBYOPT", + "RUBYSHELL", + "RUSTC_WRAPPER", + "SBT_OPTS", + "SHELL", + "SHELLOPTS", + "SSLKEYLOGFILE", + "SUDO_ASKPASS", + "SVN_EDITOR", + "SVN_SSH", + "VAGRANT_VAGRANTFILE", + "VIMINIT" + ], + "reportedDangerousOverrideOnlyKeys": [ + "ALL_PROXY", + "AMQP_URL", + "ANSIBLE_CALLBACK_PLUGINS", + "ANSIBLE_COLLECTIONS_PATH", + "ANSIBLE_CONFIG", + "ANSIBLE_CONNECTION_PLUGINS", + "ANSIBLE_FILTER_PLUGINS", + "ANSIBLE_INVENTORY_PLUGINS", + "ANSIBLE_LIBRARY", + "ANSIBLE_LOOKUP_PLUGINS", + "ANSIBLE_MODULE_UTILS", + "ANSIBLE_REMOTE_TEMP", + "ANSIBLE_ROLES_PATH", + "ANSIBLE_STRATEGY_PLUGINS", + "AWS_ACCESS_KEY_ID", + "AWS_CONFIG_FILE", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_SECRET_ACCESS_KEY", + "AWS_SECURITY_TOKEN", + "AWS_SESSION_TOKEN", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AZURE_AUTH_LOCATION", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "BUN_CONFIG_REGISTRY", + "BUNDLE_GEMFILE", + "C_INCLUDE_PATH", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_HOME", + "CFLAGS", + "CGO_CFLAGS", + "CGO_LDFLAGS", + "CLASSPATH", + "COMPOSER_HOME", + "CORECLR_PROFILER_PATH", + "CPATH", + "CPLUS_INCLUDE_PATH", + "CURL_CA_BUNDLE", + "CURL_HOME", + "DATABASE_URL", + "DENO_DIR", + "DOCKER_CERT_PATH", + "DOCKER_CONTEXT", + "DOCKER_HOST", + "DOCKER_TLS_VERIFY", + "EDITOR", + "FCEDIT", + "GEM_HOME", + "GEM_PATH", + "GH_TOKEN", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_ASKPASS", + "GIT_COMMON_DIR", + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_PAGER", + "GIT_PROXY_COMMAND", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_SSL_CAINFO", + "GIT_SSL_CAPATH", + "GIT_SSL_NO_VERIFY", + "GIT_WORK_TREE", + "GITHUB_TOKEN", + "GITLAB_TOKEN", + "GOENV", + "GOFLAGS", + "GONOPROXY", + "GONOSUMCHECK", + "GONOSUMDB", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOPATH", + "GOPRIVATE", + "GOPROXY", + "GRADLE_USER_HOME", + "HELM_HOME", + "HGRCPATH", + "HISTFILE", + "HOME", + "HTTP_PROXY", + "HTTPS_PROXY", + "KUBECONFIG", + "LDFLAGS", + "LESSCLOSE", + "LESSOPEN", + "LIBRARY_PATH", + "LUA_CPATH", + "LUA_PATH", + "MAKEFLAGS", + "MANPAGER", + "MFLAGS", + "MONGODB_URI", + "NO_PROXY", + "NODE_AUTH_TOKEN", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NPM_TOKEN", + "OBJC_INCLUDE_PATH", + "OPENSSL_CONF", + "OPENSSL_ENGINES", + "PAGER", + "PERL5DB", + "PERL5DBCMD", + "PHP_INI_SCAN_DIR", + "PHPRC", + "PIP_CONFIG_FILE", + "PIP_EXTRA_INDEX_URL", + "PIP_FIND_LINKS", + "PIP_INDEX_URL", + "PIP_PYPI_URL", + "PIP_TRUSTED_HOST", + "PROMPT_COMMAND", + "PYTHONSTARTUP", + "PYTHONUSERBASE", + "R_LIBS_USER", + "REDIS_URL", + "REQUESTS_CA_BUNDLE", + "RUSTC_WRAPPER", + "RUSTFLAGS", + "SSH_ASKPASS", + "SSH_AUTH_SOCK", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "SUDO_EDITOR", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", + "UV_DEFAULT_INDEX", + "UV_EXTRA_INDEX_URL", + "UV_INDEX", + "UV_INDEX_URL", + "UV_PYTHON", + "VIRTUAL_ENV", + "VISUAL", + "WGETRC", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "YARN_RC_FILENAME", + "ZDOTDIR" + ], + "expectedTotalReportedEntries": 232 +} diff --git a/src/infra/host-env-security.reported-baseline.test.ts b/src/infra/host-env-security.reported-baseline.test.ts new file mode 100644 index 00000000000..261abec09c7 --- /dev/null +++ b/src/infra/host-env-security.reported-baseline.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, + isDangerousHostInheritedEnvVarName, + sanitizeHostExecEnv, + sanitizeHostExecEnvWithDiagnostics, +} from "./host-env-security.js"; + +type HostEnvReportedBaseline = { + source: string; + generatedAt: string; + reportedDangerousEverywhereKeys: string[]; + reportedDangerousOverrideOnlyKeys: string[]; + expectedTotalReportedEntries: number; +}; + +const INHERITED_ALLOWLIST_RATIONALE: Record = { + ALL_PROXY: "Trusted inherited global proxy route from operator runtime.", + AWS_CONFIG_FILE: "Trusted inherited AWS CLI/SDK config path selected by operator.", + AWS_SHARED_CREDENTIALS_FILE: + "Trusted inherited AWS shared credentials path selected by operator.", + AWS_WEB_IDENTITY_TOKEN_FILE: "Trusted inherited AWS web identity token path.", + AZURE_AUTH_LOCATION: "Trusted inherited Azure auth location selected by operator.", + CURL_CA_BUNDLE: "Trusted inherited CA bundle path for TLS validation.", + DOCKER_CERT_PATH: "Trusted inherited Docker client certificate location.", + DOCKER_CONTEXT: "Trusted inherited Docker context selector from operator runtime.", + DOCKER_HOST: "Trusted inherited Docker endpoint selected by operator.", + DOCKER_TLS_VERIFY: "Trusted inherited Docker TLS verification mode.", + GIT_PAGER: "Trusted inherited interactive pager preference.", + GOOGLE_APPLICATION_CREDENTIALS: + "Trusted inherited Google application credentials path selected by operator.", + GRADLE_USER_HOME: "Trusted inherited tool cache directory location.", + HISTFILE: "Trusted inherited shell history path.", + HOME: "Trusted inherited process home-directory context.", + HTTPS_PROXY: "Trusted inherited HTTPS proxy route from operator runtime.", + HTTP_PROXY: "Trusted inherited HTTP proxy route from operator runtime.", + KUBECONFIG: "Trusted inherited Kubernetes config path selected by operator.", + MANPAGER: "Trusted inherited manual-page pager preference.", + NODE_EXTRA_CA_CERTS: "Trusted inherited extra Node CA trust roots.", + NODE_TLS_REJECT_UNAUTHORIZED: "Trusted inherited Node TLS mode from runtime policy.", + NO_PROXY: "Trusted inherited proxy bypass list from operator runtime.", + PAGER: "Trusted inherited default pager preference.", + REQUESTS_CA_BUNDLE: "Trusted inherited Python requests CA bundle path.", + SSH_AUTH_SOCK: "Trusted inherited SSH agent socket from operator runtime.", + SSL_CERT_DIR: "Trusted inherited OpenSSL certificate directory path.", + SSL_CERT_FILE: "Trusted inherited OpenSSL certificate file path.", + ZDOTDIR: "Trusted inherited shell startup directory boundary.", +}; + +function readBaselineAndPolicy(): { + baseline: HostEnvReportedBaseline; + allowedInheritedOverrideOnlyKeys: string[]; +} { + const repoRoot = process.cwd(); + const baselinePath = path.join(repoRoot, "src/infra/host-env-security.reported-baseline.json"); + const policyPath = path.join(repoRoot, "src/infra/host-env-security-policy.json"); + const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8")) as HostEnvReportedBaseline; + const policy = JSON.parse(fs.readFileSync(policyPath, "utf8")) as { + allowedInheritedOverrideOnlyKeys?: string[]; + }; + return { + baseline, + allowedInheritedOverrideOnlyKeys: (policy.allowedInheritedOverrideOnlyKeys ?? []).map((key) => + key.toUpperCase(), + ), + }; +} + +function sortUniqueUpper(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.toUpperCase()))).toSorted((a, b) => + a.localeCompare(b), + ); +} + +describe("host env reported baseline coverage", () => { + it("keeps the fixed reported dangerous env baseline fully covered by inherited + override sanitization", () => { + const { baseline, allowedInheritedOverrideOnlyKeys } = readBaselineAndPolicy(); + + expect( + baseline.reportedDangerousEverywhereKeys.length + + baseline.reportedDangerousOverrideOnlyKeys.length, + ).toBe(baseline.expectedTotalReportedEntries); + expect(baseline.expectedTotalReportedEntries).toBe(232); + expect(sortUniqueUpper(baseline.reportedDangerousEverywhereKeys)).toEqual( + baseline.reportedDangerousEverywhereKeys, + ); + expect(sortUniqueUpper(baseline.reportedDangerousOverrideOnlyKeys)).toEqual( + baseline.reportedDangerousOverrideOnlyKeys, + ); + + const inheritedInput: Record = { + PATH: "/usr/bin:/bin", + }; + for (const key of baseline.reportedDangerousEverywhereKeys) { + inheritedInput[key] = `${key.toLowerCase()}-from-inherited`; + } + for (const key of baseline.reportedDangerousOverrideOnlyKeys) { + inheritedInput[key] = `${key.toLowerCase()}-from-inherited`; + } + const inheritedSanitized = sanitizeHostExecEnv({ baseEnv: inheritedInput }); + + for (const key of baseline.reportedDangerousEverywhereKeys) { + expect(isDangerousHostEnvVarName(key)).toBe(true); + expect(isDangerousHostInheritedEnvVarName(key)).toBe(true); + expect(inheritedSanitized[key]).toBeUndefined(); + } + + const inheritedAllowlist = new Set(allowedInheritedOverrideOnlyKeys); + for (const key of baseline.reportedDangerousOverrideOnlyKeys) { + expect(isDangerousHostEnvOverrideVarName(key)).toBe(true); + if (inheritedAllowlist.has(key)) { + expect(isDangerousHostInheritedEnvVarName(key)).toBe(false); + expect(inheritedSanitized[key]).toBe(`${key.toLowerCase()}-from-inherited`); + } else { + expect(isDangerousHostInheritedEnvVarName(key)).toBe(true); + expect(inheritedSanitized[key]).toBeUndefined(); + } + } + + const overrideInput: Record = {}; + for (const key of baseline.reportedDangerousEverywhereKeys) { + overrideInput[key] = `${key.toLowerCase()}-from-override`; + } + for (const key of baseline.reportedDangerousOverrideOnlyKeys) { + overrideInput[key] = `${key.toLowerCase()}-from-override`; + } + + const overrideResult = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { PATH: "/usr/bin:/bin" }, + overrides: overrideInput, + }); + const expectedRejectedOverrideKeys = sortUniqueUpper([ + ...baseline.reportedDangerousEverywhereKeys, + ...baseline.reportedDangerousOverrideOnlyKeys, + ]); + expect(overrideResult.rejectedOverrideBlockedKeys).toEqual(expectedRejectedOverrideKeys); + expect(overrideResult.rejectedOverrideInvalidKeys).toEqual([]); + + for (const key of expectedRejectedOverrideKeys) { + expect(overrideResult.env[key]).toBeUndefined(); + } + }); + + it("documents and enforces rationale for every inherited allowlist exception", () => { + const { allowedInheritedOverrideOnlyKeys } = readBaselineAndPolicy(); + const expectedAllowlistKeys = Object.keys(INHERITED_ALLOWLIST_RATIONALE).toSorted((a, b) => + a.localeCompare(b), + ); + expect(allowedInheritedOverrideOnlyKeys.toSorted((a, b) => a.localeCompare(b))).toEqual( + expectedAllowlistKeys, + ); + + for (const key of expectedAllowlistKeys) { + expect(INHERITED_ALLOWLIST_RATIONALE[key].trim().length).toBeGreaterThan(0); + expect(isDangerousHostInheritedEnvVarName(key)).toBe(false); + expect(isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)).toBe(true); + + const inheritedSanitized = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + [key]: `${key.toLowerCase()}-trusted-inherited`, + }, + }); + expect(inheritedSanitized[key]).toBe(`${key.toLowerCase()}-trusted-inherited`); + + const overrideResult = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { PATH: "/usr/bin:/bin" }, + overrides: { + [key]: `${key.toLowerCase()}-untrusted-override`, + }, + }); + expect(overrideResult.rejectedOverrideBlockedKeys).toEqual([key]); + expect(overrideResult.rejectedOverrideInvalidKeys).toEqual([]); + expect(overrideResult.env[key]).toBeUndefined(); + } + }); +}); diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index a598c8df3f1..8c163705660 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { isDangerousHostEnvOverrideVarName, + isDangerousHostInheritedEnvVarName, isDangerousHostEnvVarName, normalizeEnvVarKey, sanitizeHostExecEnv, @@ -223,6 +224,68 @@ describe("isDangerousHostEnvVarName", () => { expect(isDangerousHostEnvVarName("FOO")).toBe(false); expect(isDangerousHostEnvVarName("GRADLE_USER_HOME")).toBe(false); }); + + it("blocks newly added startup, orchestration, and resolver env keys", () => { + const keys = [ + "VIMINIT", + "EXINIT", + "MYVIMRC", + "GVIMINIT", + "LUA_INIT", + "LUA_INIT_5_4", + "HOSTALIASES", + "CONFIG_SITE", + "CONFIG_SHELL", + "CMAKE_TOOLCHAIN_FILE", + "ERL_AFLAGS", + "ERL_FLAGS", + "ERL_ZFLAGS", + "R_ENVIRON", + "R_PROFILE_USER", + ] as const; + + for (const key of keys) { + expect(isDangerousHostEnvVarName(key)).toBe(true); + expect(isDangerousHostEnvVarName(key.toLowerCase())).toBe(true); + } + + expect(isDangerousHostEnvVarName("ANSIBLE_CONFIG")).toBe(false); + expect(isDangerousHostEnvVarName("ANSIBLE_LIBRARY")).toBe(false); + expect(isDangerousHostEnvVarName("TF_CLI_CONFIG_FILE")).toBe(false); + expect(isDangerousHostEnvVarName("AWS_CONTAINER_CREDENTIALS_FULL_URI")).toBe(false); + expect(isDangerousHostEnvVarName("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")).toBe(false); + }); +}); + +describe("isDangerousHostInheritedEnvVarName", () => { + it("blocks inherited keys from both policy buckets while preserving explicit inherited allowlist keys", () => { + expect(isDangerousHostInheritedEnvVarName("BASH_ENV")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("bash_env")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("ANSIBLE_CONFIG")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("ansible_library")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("TF_CLI_CONFIG_FILE")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("TF_VAR_admin_cidr")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("AWS_CONTAINER_CREDENTIALS_FULL_URI")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")).toBe(true); + expect(isDangerousHostInheritedEnvVarName("KUBECONFIG")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("GOOGLE_APPLICATION_CREDENTIALS")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("AWS_SHARED_CREDENTIALS_FILE")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("AWS_WEB_IDENTITY_TOKEN_FILE")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("AWS_CONFIG_FILE")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("AZURE_AUTH_LOCATION")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("SSH_AUTH_SOCK")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("DOCKER_CONTEXT")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("GIT_CONFIG_GLOBAL")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("NPM_CONFIG_USERCONFIG")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("CARGO_REGISTRIES_CRATES_IO_INDEX")).toBe(false); + + expect(isDangerousHostInheritedEnvVarName("HTTP_PROXY")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("https_proxy")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("SSL_CERT_FILE")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("node_extra_ca_certs")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("HOME")).toBe(false); + expect(isDangerousHostInheritedEnvVarName("FOO")).toBe(false); + }); }); describe("sanitizeHostExecEnv", () => { @@ -255,12 +318,14 @@ describe("sanitizeHostExecEnv", () => { AWS_WEB_IDENTITY_TOKEN_FILE: "/tmp/aws-web-token", AZURE_AUTH_LOCATION: "/tmp/azure-auth.json", AWS_CONFIG_FILE: "/tmp/aws-config", + SSH_AUTH_SOCK: "/tmp/trusted-ssh-agent.sock", CARGO_HOME: "/tmp/cargo", HELM_HOME: "/tmp/helm", HTTP_PROXY: "http://proxy.example.test:8080", HTTPS_PROXY: "http://proxy.example.test:8443", SSL_CERT_FILE: "/tmp/evil-cert.pem", SSL_CERT_DIR: "/tmp/evil-cert-dir", + DOCKER_CONTEXT: "trusted-remote", DOCKER_HOST: "tcp://docker.example.test:2376", LD_PRELOAD: "/tmp/pwn.so", OK: "1", @@ -276,12 +341,12 @@ describe("sanitizeHostExecEnv", () => { AWS_SHARED_CREDENTIALS_FILE: "/tmp/aws-credentials", AWS_WEB_IDENTITY_TOKEN_FILE: "/tmp/aws-web-token", AZURE_AUTH_LOCATION: "/tmp/azure-auth.json", - CARGO_HOME: "/tmp/cargo", - HELM_HOME: "/tmp/helm", + SSH_AUTH_SOCK: "/tmp/trusted-ssh-agent.sock", HTTP_PROXY: "http://proxy.example.test:8080", HTTPS_PROXY: "http://proxy.example.test:8443", SSL_CERT_FILE: "/tmp/evil-cert.pem", SSL_CERT_DIR: "/tmp/evil-cert-dir", + DOCKER_CONTEXT: "trusted-remote", DOCKER_HOST: "tcp://docker.example.test:2376", OK: "1", }); @@ -428,7 +493,7 @@ describe("sanitizeHostExecEnv", () => { expect(env.MFLAGS).toBeUndefined(); expect(env.PHPRC).toBeUndefined(); expect(env.XDG_CONFIG_HOME).toBeUndefined(); - expect(env.YARN_RC_FILENAME).toBe(".trusted-yarnrc.yml"); + expect(env.YARN_RC_FILENAME).toBeUndefined(); expect(env.PIP_INDEX_URL).toBeUndefined(); expect(env.PIP_PYPI_URL).toBeUndefined(); expect(env.PIP_EXTRA_INDEX_URL).toBeUndefined(); @@ -470,6 +535,110 @@ describe("sanitizeHostExecEnv", () => { expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir"); }); + it("drops inherited vars blocked by either policy bucket and keeps explicit inherited allowlist keys", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + HTTPS_PROXY: "http://trusted-proxy.example.test:8443", + KUBECONFIG: "/tmp/trusted-kubeconfig", + GOOGLE_APPLICATION_CREDENTIALS: "/tmp/trusted-gcp.json", + AWS_SHARED_CREDENTIALS_FILE: "/tmp/trusted-aws-credentials", + AWS_WEB_IDENTITY_TOKEN_FILE: "/tmp/trusted-aws-web-token", + AWS_CONFIG_FILE: "/tmp/trusted-aws-config", + AZURE_AUTH_LOCATION: "/tmp/trusted-azure-auth.json", + SSH_AUTH_SOCK: "/tmp/trusted-ssh-agent.sock", + DOCKER_CONTEXT: "trusted-remote", + VIMINIT: ":!touch /tmp/pwned", + EXINIT: "silent !touch /tmp/pwned", + LUA_INIT_5_4: "os.execute('touch /tmp/pwned')", + HOSTALIASES: "/tmp/evil-hostaliases", + AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://169.254.170.2/credentials", + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/v2/credentials/abcd", + CONFIG_SITE: "/tmp/evil-config-site", + ANSIBLE_CONFIG: "/tmp/evil-ansible.cfg", + R_PROFILE_USER: "/tmp/evil-Rprofile", + ERL_AFLAGS: "-eval 'os:cmd(\"id\")'", + TF_CLI_CONFIG_FILE: "/tmp/evil-terraformrc", + TF_VAR_admin_cidr: "10.0.0.0/24", + SAFE: "1", + }, + }); + + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); + expect(env.VIMINIT).toBeUndefined(); + expect(env.EXINIT).toBeUndefined(); + expect(env.LUA_INIT_5_4).toBeUndefined(); + expect(env.HOSTALIASES).toBeUndefined(); + expect(env.HTTPS_PROXY).toBe("http://trusted-proxy.example.test:8443"); + expect(env.KUBECONFIG).toBe("/tmp/trusted-kubeconfig"); + expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBe("/tmp/trusted-gcp.json"); + expect(env.AWS_SHARED_CREDENTIALS_FILE).toBe("/tmp/trusted-aws-credentials"); + expect(env.AWS_WEB_IDENTITY_TOKEN_FILE).toBe("/tmp/trusted-aws-web-token"); + expect(env.AWS_CONFIG_FILE).toBe("/tmp/trusted-aws-config"); + expect(env.AZURE_AUTH_LOCATION).toBe("/tmp/trusted-azure-auth.json"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/trusted-ssh-agent.sock"); + expect(env.DOCKER_CONTEXT).toBe("trusted-remote"); + expect(env.AWS_CONTAINER_CREDENTIALS_FULL_URI).toBeUndefined(); + expect(env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI).toBeUndefined(); + expect(env.CONFIG_SITE).toBeUndefined(); + expect(env.ANSIBLE_CONFIG).toBeUndefined(); + expect(env.R_PROFILE_USER).toBeUndefined(); + expect(env.ERL_AFLAGS).toBeUndefined(); + expect(env.TF_CLI_CONFIG_FILE).toBeUndefined(); + expect(env.TF_VAR_admin_cidr).toBe("10.0.0.0/24"); + expect(env.SAFE).toBe("1"); + }); + + it("drops newly blocked override credential and startup vars", () => { + const env = sanitizeHostExecEnv({ + baseEnv: { + PATH: "/usr/bin:/bin", + }, + overrides: { + VIMINIT: ":!touch /tmp/pwned", + HOSTALIASES: "/tmp/evil-hostaliases", + AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://attacker/credentials", + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/attacker-credentials", + ANSIBLE_CONFIG: "/tmp/override-ansible.cfg", + ANSIBLE_REMOTE_TEMP: "/tmp/evil-ansible-remote", + R_LIBS_USER: "/tmp/evil-r-libs-user", + TF_CLI_CONFIG_FILE: "/tmp/override-terraformrc", + TF_PLUGIN_CACHE_DIR: "/tmp/evil-tf-plugin-cache", + CFLAGS: "-I/attacker/include", + LDFLAGS: "-L/attacker/lib", + XDG_CONFIG_DIRS: "/tmp/evil-config-dirs", + TF_VAR_admin_cidr: "10.0.0.0/24", + GITHUB_TOKEN: "ghp-test", + DATABASE_URL: "postgres://attacker", + NPM_TOKEN: "npm-test", + SSH_AUTH_SOCK: "/tmp/evil-agent.sock", + SAFE: "ok", + }, + }); + + expect(env.PATH).toBe("/usr/bin:/bin"); + expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE); + expect(env.VIMINIT).toBeUndefined(); + expect(env.HOSTALIASES).toBeUndefined(); + expect(env.AWS_CONTAINER_CREDENTIALS_FULL_URI).toBeUndefined(); + expect(env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI).toBeUndefined(); + expect(env.ANSIBLE_CONFIG).toBeUndefined(); + expect(env.ANSIBLE_REMOTE_TEMP).toBeUndefined(); + expect(env.R_LIBS_USER).toBeUndefined(); + expect(env.TF_CLI_CONFIG_FILE).toBeUndefined(); + expect(env.TF_PLUGIN_CACHE_DIR).toBeUndefined(); + expect(env.CFLAGS).toBeUndefined(); + expect(env.LDFLAGS).toBeUndefined(); + expect(env.XDG_CONFIG_DIRS).toBeUndefined(); + expect(env.TF_VAR_admin_cidr).toBeUndefined(); + expect(env.GITHUB_TOKEN).toBeUndefined(); + expect(env.DATABASE_URL).toBeUndefined(); + expect(env.NPM_TOKEN).toBeUndefined(); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + expect(env.SAFE).toBe("ok"); + }); + it("keeps trusted inherited proxy and TLS env while blocking overrides", () => { const env = sanitizeHostExecEnv({ baseEnv: { @@ -658,16 +827,53 @@ describe("isDangerousHostEnvOverrideVarName", () => { expect(isDangerousHostEnvOverrideVarName("cargo_build_rustc_wrapper")).toBe(true); expect(isDangerousHostEnvOverrideVarName("CARGO_HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("cargo_home")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("TF_VAR_admin_cidr")).toBe(true); expect(isDangerousHostEnvOverrideVarName("CORECLR_PROFILER_PATH")).toBe(true); expect(isDangerousHostEnvOverrideVarName("coreclr_profiler_path")).toBe(true); expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_HOME")).toBe(true); expect(isDangerousHostEnvOverrideVarName("xdg_config_home")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_DIRS")).toBe(true); + expect(isDangerousHostEnvOverrideVarName("xdg_config_dirs")).toBe(true); expect(isDangerousHostEnvOverrideVarName("AWS_CONFIG_FILE")).toBe(true); expect(isDangerousHostEnvOverrideVarName("aws_config_file")).toBe(true); expect(isDangerousHostEnvOverrideVarName("yarn_rc_filename")).toBe(true); expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false); expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false); }); + + it("blocks newly added credential and build influence keys", () => { + const keys = [ + "GITHUB_TOKEN", + "GH_TOKEN", + "GITLAB_TOKEN", + "NPM_TOKEN", + "NODE_AUTH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "ANSIBLE_CONFIG", + "ANSIBLE_LIBRARY", + "ANSIBLE_REMOTE_TEMP", + "R_LIBS_USER", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", + "CFLAGS", + "LDFLAGS", + "XDG_CONFIG_DIRS", + "AWS_SECRET_ACCESS_KEY", + "AZURE_CLIENT_SECRET", + "DATABASE_URL", + "REDIS_URL", + "MONGODB_URI", + "AMQP_URL", + "SSH_AUTH_SOCK", + ] as const; + + for (const key of keys) { + expect(isDangerousHostEnvOverrideVarName(key)).toBe(true); + expect(isDangerousHostEnvOverrideVarName(key.toLowerCase())).toBe(true); + } + }); }); describe("sanitizeHostExecEnvWithDiagnostics", () => { @@ -886,6 +1092,65 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { expect(result.env.YARN_RC_FILENAME).toBeUndefined(); }); + it("reports newly blocked keys from everywhere and override buckets", () => { + const result = sanitizeHostExecEnvWithDiagnostics({ + baseEnv: { + PATH: "/usr/bin:/bin", + }, + overrides: { + VIMINIT: ":!touch /tmp/pwned", + LUA_INIT_5_4: "os.execute('touch /tmp/pwned')", + HOSTALIASES: "/tmp/evil-hostaliases", + ANSIBLE_CONFIG: "/tmp/evil-ansible.cfg", + ANSIBLE_REMOTE_TEMP: "/tmp/evil-ansible-remote", + R_LIBS_USER: "/tmp/evil-r-libs-user", + TF_CLI_CONFIG_FILE: "/tmp/evil-terraformrc", + TF_PLUGIN_CACHE_DIR: "/tmp/evil-tf-plugin-cache", + AWS_CONTAINER_CREDENTIALS_FULL_URI: "http://attacker/credentials", + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: "/attacker-credentials", + GITHUB_TOKEN: "ghp-test", + DATABASE_URL: "postgres://attacker", + R_PROFILE_USER: "/tmp/evil-Rprofile", + XDG_CONFIG_DIRS: "/tmp/evil-config-dirs", + TF_VAR_admin_cidr: "10.0.0.0/24", + SAFE_KEY: "ok", + }, + }); + + expect(result.rejectedOverrideBlockedKeys).toEqual([ + "ANSIBLE_CONFIG", + "ANSIBLE_REMOTE_TEMP", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "DATABASE_URL", + "GITHUB_TOKEN", + "HOSTALIASES", + "LUA_INIT_5_4", + "R_LIBS_USER", + "R_PROFILE_USER", + "TF_CLI_CONFIG_FILE", + "TF_PLUGIN_CACHE_DIR", + "TF_VAR_ADMIN_CIDR", + "VIMINIT", + "XDG_CONFIG_DIRS", + ]); + expect(result.rejectedOverrideInvalidKeys).toEqual([]); + expect(result.env.SAFE_KEY).toBe("ok"); + expect(result.env.VIMINIT).toBeUndefined(); + expect(result.env.LUA_INIT_5_4).toBeUndefined(); + expect(result.env.HOSTALIASES).toBeUndefined(); + expect(result.env.ANSIBLE_CONFIG).toBeUndefined(); + expect(result.env.ANSIBLE_REMOTE_TEMP).toBeUndefined(); + expect(result.env.R_LIBS_USER).toBeUndefined(); + expect(result.env.TF_CLI_CONFIG_FILE).toBeUndefined(); + expect(result.env.TF_PLUGIN_CACHE_DIR).toBeUndefined(); + expect(result.env.GITHUB_TOKEN).toBeUndefined(); + expect(result.env.DATABASE_URL).toBeUndefined(); + expect(result.env.R_PROFILE_USER).toBeUndefined(); + expect(result.env.XDG_CONFIG_DIRS).toBeUndefined(); + expect(result.env.TF_VAR_admin_cidr).toBeUndefined(); + }); + it("allows Windows-style override names while still rejecting invalid keys", () => { const result = sanitizeHostExecEnvWithDiagnostics({ baseEnv: { diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 8d82bdc76a9..3b93f3e5637 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -10,6 +10,12 @@ export const HOST_DANGEROUS_ENV_KEY_VALUES: readonly string[] = Object.freeze([ export const HOST_DANGEROUS_ENV_PREFIXES: readonly string[] = Object.freeze([ ...HOST_ENV_SECURITY_POLICY.blockedPrefixes, ]); +export const HOST_DANGEROUS_INHERITED_ENV_KEY_VALUES: readonly string[] = Object.freeze([ + ...HOST_ENV_SECURITY_POLICY.blockedInheritedKeys, +]); +export const HOST_DANGEROUS_INHERITED_ENV_PREFIXES: readonly string[] = Object.freeze([ + ...HOST_ENV_SECURITY_POLICY.blockedInheritedPrefixes, +]); export const HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES: readonly string[] = Object.freeze([ ...HOST_ENV_SECURITY_POLICY.blockedOverrideKeys, ]); @@ -27,6 +33,9 @@ export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES: readonly string "FORCE_COLOR", ]); export const HOST_DANGEROUS_ENV_KEYS = new Set(HOST_DANGEROUS_ENV_KEY_VALUES); +export const HOST_DANGEROUS_INHERITED_ENV_KEYS = new Set( + HOST_DANGEROUS_INHERITED_ENV_KEY_VALUES, +); export const HOST_DANGEROUS_OVERRIDE_ENV_KEYS = new Set( HOST_DANGEROUS_OVERRIDE_ENV_KEY_VALUES, ); @@ -82,6 +91,18 @@ export function isDangerousHostEnvVarName(rawKey: string): boolean { return HOST_DANGEROUS_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); } +export function isDangerousHostInheritedEnvVarName(rawKey: string): boolean { + const key = normalizeEnvVarKey(rawKey); + if (!key) { + return false; + } + const upper = key.toUpperCase(); + if (HOST_DANGEROUS_INHERITED_ENV_KEYS.has(upper)) { + return true; + } + return HOST_DANGEROUS_INHERITED_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); +} + export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean { const key = normalizeEnvVarKey(rawKey); if (!key) { @@ -178,7 +199,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: { const merged: Record = {}; for (const [key, value] of listNormalizedEnvEntries(baseEnv)) { - if (isDangerousHostEnvVarName(key)) { + if (isDangerousHostInheritedEnvVarName(key)) { continue; } merged[key] = value;