From 9b7dcca84110ac8dd1371d12ed20bc73e596d51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20Almaza=20Ramiro?= Date: Fri, 14 Aug 2026 11:48:14 +0200 Subject: [PATCH 1/4] test(e2e): restore Windows agent-profile helpers on list/describe PR Move writeProcessesDYamlContent and process-owner helpers here where the agent-profile E2E tests use them, after dropping them from spawn-profiles. --- Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index cc54fab6d53d..17d48191b8fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,7 +734,6 @@ dependencies = [ "clap", "dd-agent-log", "dd-procmgr-client", - "hyper-util", "libc", "log", "nix", From e408427fb9ee91d69135affb3c3fa2248abe5715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20Almaza=20Ramiro?= Date: Tue, 11 Aug 2026 17:29:20 +0200 Subject: [PATCH 2/4] feat(procmgr): secret backend resolution for config gates (split PR 3/4) Resolve ENC[...] gate values via secret_backend_command and native backends, matching Agent precedence and spawning backends under the core Agent account. --- pkg/procmgr/rust/src/config_gate.rs | 764 ++++++++++++-- .../rust/src/config_gate/env_bindings.rs | 23 +- pkg/procmgr/rust/src/config_gate/secrets.rs | 987 ++++++++++++++++++ pkg/procmgr/rust/src/lib.rs | 1 + pkg/procmgr/rust/src/platform/unix/mod.rs | 7 + .../rust/src/platform/unix/secret_backend.rs | 101 ++ pkg/procmgr/rust/src/platform/windows/mod.rs | 9 + .../src/platform/windows/secret_backend.rs | 502 +++++++++ .../platform/windows/secret_backend_rights.rs | 267 +++++ pkg/procmgr/rust/src/secret_backend_exec.rs | 264 +++++ 10 files changed, 2829 insertions(+), 96 deletions(-) create mode 100644 pkg/procmgr/rust/src/config_gate/secrets.rs create mode 100644 pkg/procmgr/rust/src/platform/unix/secret_backend.rs create mode 100644 pkg/procmgr/rust/src/platform/windows/secret_backend.rs create mode 100644 pkg/procmgr/rust/src/platform/windows/secret_backend_rights.rs create mode 100644 pkg/procmgr/rust/src/secret_backend_exec.rs diff --git a/pkg/procmgr/rust/src/config_gate.rs b/pkg/procmgr/rust/src/config_gate.rs index a156677bc7ea..b3760c38f3db 100644 --- a/pkg/procmgr/rust/src/config_gate.rs +++ b/pkg/procmgr/rust/src/config_gate.rs @@ -3,7 +3,27 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2026-present Datadog, Inc. +//! Optional `condition_config_any` gates for processes.d definitions. +//! +//! Mirrors the Windows legacy SCM startup checks in +//! `cmd/agent/subcommands/run/dependent_services_windows.go`: start only when any +//! configured key evaluates to true. Resolution order matches agent config +//! (`pkg/config/model/types.go`): agent-runtime transforms, then highest-priority +//! configured source among fleet policy, secret-backed values from pre-fleet +//! layers (base YAML and environment), environment variables, explicit base +//! YAML, then agent default. Fleet policy `ENC[...]` handles are left unresolved, +//! matching Agent `MergeFleetPolicy` running after secret resolution. +//! +//! When deprecated `process_config.enabled` is set, collection keys follow +//! `loadProcessTransforms` in `pkg/config/setup/process.go` instead of defaults. +//! +//! Derived `system_probe_config.enabled` (module knobs) is implemented in +//! [`system_probe`] and must stay in sync with `pkg/system-probe/config/config.go`. +//! Env bindings are centralized in [`env_bindings`]. + + mod env_bindings; +mod secrets; mod system_probe; mod yaml_load; @@ -84,17 +104,17 @@ impl ProcessEnabledMode { } const LEGACY_PROCESS_ENABLED_KEY: &str = "process_config.enabled"; -const LEGACY_FLEET_POLICY_FILE: &str = "datadog.yaml"; impl GatedKeySpec { /// Resolution order (most keys): legacy `process_config.enabled` transform (collection keys only) - /// → fleet policy → env → base YAML → agent default. + /// → highest-priority configured source (fleet, secret, env, file) → agent default. /// /// `system_probe_config.enabled` is special: returns [`system_probe::derived_enabled`] only, /// mirroring post-`load()`/`Adjust` `GetBool` (module-derived runtime value). /// - /// Legacy transforms mirror `loadProcessTransforms`. Fleet policy outranks env vars - /// (`SourceFleetPolicies` > `SourceEnvVar`). + /// Legacy transforms mirror `loadProcessTransforms`, which runs before fleet merge and writes + /// collection keys at agent-runtime precedence. The deprecated key is read from env or base YAML + /// only (fleet is ignored for this transform). fn enabled(&self, base_path: &str, yaml: &mut YamlCache) -> anyhow::Result { if let Some(enabled) = self.legacy_collection_override(base_path, yaml)? { return Ok(enabled); @@ -104,16 +124,12 @@ impl GatedKeySpec { // runtime enabled is module-derived, not the literal YAML/env knob alone. return system_probe::derived_enabled(base_path, yaml); } - if let Some(enabled) = self.fleet_policy_value(base_path, yaml)? { - return Ok(enabled); - } - if let Some(enabled) = self.env_override() { - return Ok(enabled); - } - if let Some(enabled) = yaml.bool_key_if_exists(base_path, self.key)? { - return Ok(enabled); - } - Ok(self.default) + yaml.resolve_bool_by_source_priority( + base_path, + self.key, + self.fleet_policy_file, + self.default, + ) } fn uses_legacy_process_enabled(&self) -> bool { @@ -142,24 +158,29 @@ impl GatedKeySpec { }; Ok(Some(enabled)) } +} - fn fleet_policy_value( - &self, - base_path: &str, - yaml: &mut YamlCache, - ) -> anyhow::Result> { - let Some(filename) = self.fleet_policy_file else { - return Ok(None); - }; - let Some(path) = yaml.fleet_policy_path(filename, base_path)? else { - return Ok(None); - }; - yaml.bool_key_if_exists(&path, self.key) +fn agent_datadog_yaml(config_path: &str) -> String { + let path = Path::new(config_path); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("datadog.yaml")) + { + return config_path.to_owned(); } + path.parent() + .map(|dir| dir.join("datadog.yaml")) + .map(|joined| joined.to_string_lossy().into_owned()) + .unwrap_or_else(|| config_path.to_owned()) +} - fn env_override(&self) -> Option { - env_bool_for_config_key(self.key) +fn resolve_fleet_policies_dir(raw: &str, agent_yaml: &str) -> Option { + let resolved = secrets::resolve_config_string(raw, agent_yaml); + if secrets::is_enc(&resolved) || resolved.trim().is_empty() { + return None; } + Some(resolved) } pub(super) struct YamlCache(HashMap); @@ -170,11 +191,12 @@ impl YamlCache { /// System-probe gates do not inherit `fleet_policies_dir` from sibling `datadog.yaml` /// (matches `applyFleetPolicy` on the system-probe config object). fn fleet_policies_dir(&mut self, config_path: &str) -> anyhow::Result> { + let agent = agent_datadog_yaml(config_path); if let Some(dir) = env_bindings::env_var_value_for_name("DD_FLEET_POLICIES_DIR") { - return Ok(Some(dir)); + return Ok(resolve_fleet_policies_dir(&dir, &agent)); } if let Some(dir) = self.fleet_policies_dir_in_yaml(config_path)? { - return Ok(Some(dir)); + return Ok(resolve_fleet_policies_dir(&dir, &agent)); } #[cfg(windows)] { @@ -207,23 +229,28 @@ impl YamlCache { })) } - /// Fleet policy → env bindings → base YAML → `false`. + /// Highest-priority configured source among fleet, secret, env, and base YAML. + pub(super) fn resolve_bool_by_source_priority( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + default: bool, + ) -> anyhow::Result { + Ok(self + .best_bool_layer(base_path, key, fleet_policy_file)? + .map(|layer| layer.value) + .unwrap_or(default)) + } + + /// Highest-priority configured source among fleet, secret, env, and base YAML. pub(super) fn resolve_bool( &mut self, base_path: &str, key: &str, fleet_policy_file: Option<&str>, ) -> anyhow::Result { - if let Some(filename) = fleet_policy_file - && let Some(path) = self.fleet_policy_path(filename, base_path)? - && let Some(value) = self.bool_key_if_exists(&path, key)? - { - return Ok(value); - } - if let Some(enabled) = env_bool_for_config_key(key) { - return Ok(enabled); - } - Ok(self.bool_key_if_exists(base_path, key)?.unwrap_or(false)) + self.resolve_bool_by_source_priority(base_path, key, fleet_policy_file, false) } /// Like [`Self::resolve_bool`] but uses `default` when the key is unset everywhere. @@ -234,16 +261,7 @@ impl YamlCache { fleet_policy_file: Option<&str>, default: bool, ) -> anyhow::Result { - if let Some(filename) = fleet_policy_file - && let Some(path) = self.fleet_policy_path(filename, base_path)? - && let Some(value) = self.bool_key_if_exists(&path, key)? - { - return Ok(value); - } - if let Some(enabled) = env_bool_for_config_key(key) { - return Ok(enabled); - } - Ok(self.bool_key_if_exists(base_path, key)?.unwrap_or(default)) + self.resolve_bool_by_source_priority(base_path, key, fleet_policy_file, default) } pub(super) fn resolve_string( @@ -252,19 +270,87 @@ impl YamlCache { key: &str, fleet_policy_file: Option<&str>, ) -> anyhow::Result> { + Ok(self + .best_string_layer(base_path, key, fleet_policy_file)? + .map(|layer| layer.value)) + } + + fn best_config_layer( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + layer_from_yaml: impl Fn(&serde_yaml::Value, &str, ConfigSourcePriority) -> Option>, + layer_from_env: impl Fn(&str, &str, ConfigSourcePriority) -> Option>, + ) -> anyhow::Result>> { + let agent_yaml = agent_datadog_yaml(base_path); + let mut best: Option> = None; + if let Some(filename) = fleet_policy_file && let Some(path) = self.fleet_policy_path(filename, base_path)? && let Some(value) = self.dotted_key_if_exists(&path, key)? { - return Self::string_value(value); + if let Some(candidate) = + layer_from_yaml(value, &agent_yaml, ConfigSourcePriority::FleetPolicies) + { + best = Some(Prioritized::pick_best(best, candidate)); + } } + if let Some(text) = env_string_for_config_key(key) { - return Ok(Some(text)); + if let Some(candidate) = + layer_from_env(&text, &agent_yaml, ConfigSourcePriority::EnvVar) + { + best = Some(Prioritized::pick_best(best, candidate)); + } } - match self.dotted_key_if_exists(base_path, key)? { - Some(value) => Self::string_value(value), - None => Ok(None), + + if let Some(value) = self.dotted_key_if_exists(base_path, key)? { + if let Some(candidate) = + layer_from_yaml(value, &agent_yaml, ConfigSourcePriority::File) + { + best = Some(Prioritized::pick_best(best, candidate)); + } } + + Ok(best) + } + + fn best_bool_layer( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + ) -> anyhow::Result>> { + self.best_config_layer( + base_path, + key, + fleet_policy_file, + prioritized_bool_from_yaml_value, + prioritized_bool_from_string, + ) + } + + fn best_string_layer( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + ) -> anyhow::Result>> { + self.best_config_layer( + base_path, + key, + fleet_policy_file, + |value, agent_yaml, priority| { + YamlCache::string_value(value) + .ok() + .flatten() + .map(|text| prioritized_string_from_raw(text, agent_yaml, priority)) + }, + |text, agent_yaml, priority| { + Some(prioritized_string_from_raw(text.to_owned(), agent_yaml, priority)) + }, + ) } /// Whether `key` is present in the base YAML file only (not fleet policy or env). @@ -318,7 +404,7 @@ impl YamlCache { let Some(value) = self.dotted_key(path, key)? else { return Ok(None); }; - value_as_bool(value) + value_as_bool(value, &agent_datadog_yaml(path)) .ok_or_else(|| anyhow::anyhow!("key {key} is not a bool")) .map(Some) } @@ -355,6 +441,13 @@ impl YamlCache { } } +/// Drop cached secret handles and backend settings before config-gate re-evaluation. +pub(crate) fn clear_secret_caches() { + secrets::clear_caches(); + #[cfg(windows)] + crate::platform::refresh_core_agent_scm_environment(); +} + /// Returns true when `conditions` is empty or any `(path, key)` pair is enabled. pub fn condition_config_any_met(conditions: &[ConditionConfigFile]) -> bool { if conditions.is_empty() { @@ -377,11 +470,7 @@ fn resolve_legacy_process_enabled_mode( base_path: &str, yaml: &mut YamlCache, ) -> anyhow::Result> { - if let Some(path) = yaml.fleet_policy_path(LEGACY_FLEET_POLICY_FILE, base_path)? - && let Some(mode) = legacy_enabled_mode_from_file(yaml, &path)? - { - return Ok(Some(mode)); - } + // loadProcessTransforms runs before MergeFleetPolicy; fleet must not drive this transform. if let Some(mode) = legacy_enabled_env_mode() { return Ok(Some(mode)); } @@ -452,7 +541,7 @@ fn config_key_enabled(path: &str, key: &str, yaml: &mut YamlCache) -> anyhow::Re .enabled(path, yaml) } -fn lookup_mapping_case_insensitive<'a>( +pub(super) fn lookup_mapping_case_insensitive<'a>( mapping: &'a serde_yaml::Mapping, key: &str, ) -> Option<&'a serde_yaml::Value> { @@ -501,17 +590,116 @@ fn lookup_dotted_key_in_mapping<'a>( lookup_dotted_key_in_mapping(next, rest) } -fn value_as_bool(value: &serde_yaml::Value) -> Option { +/// Mirrors `pkg/config/model/types.go` source precedence for user-provided layers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum ConfigSourcePriority { + File = 3, + EnvVar = 4, + FleetPolicies = 5, + Secret = 7, +} + +struct Prioritized { + value: T, + priority: ConfigSourcePriority, +} + +impl Prioritized { + fn pick_best(current: Option, candidate: Self) -> Self { + match current { + Some(existing) if existing.priority >= candidate.priority => existing, + _ => candidate, + } + } +} + +fn prioritized_bool_from_yaml_value( + value: &serde_yaml::Value, + agent_yaml: &str, + base_priority: ConfigSourcePriority, +) -> Option> { match value { // Plain YAML 1.1 bools (`yes`/`on`/…) are coerced to bool at load time in [`yaml_load`]. - serde_yaml::Value::Bool(enabled) => Some(*enabled), - serde_yaml::Value::Number(number) => number.as_i64().map(|n| n != 0), - // Quoted scalars and env vars: `strconv.ParseBool` only. - serde_yaml::Value::String(text) => Some(parse_agent_bool_string(text).unwrap_or(false)), + serde_yaml::Value::Bool(enabled) => Some(Prioritized { + value: *enabled, + priority: base_priority, + }), + serde_yaml::Value::Number(number) => { + let value = number + .as_i64() + .map(|n| n != 0) + .or_else(|| number.as_f64().map(|n| n != 0.0))?; + Some(Prioritized { + value, + priority: base_priority, + }) + } + // Quoted scalars and env vars: secret resolution then `strconv.ParseBool`. + serde_yaml::Value::String(text) => { + prioritized_bool_from_string(text, agent_yaml, base_priority) + } _ => None, } } +fn prioritized_bool_from_string( + text: &str, + agent_yaml: &str, + base_priority: ConfigSourcePriority, +) -> Option> { + if base_priority != ConfigSourcePriority::FleetPolicies + && let Some((resolved, priority)) = promote_secret_string(text, agent_yaml) + { + return parse_agent_bool_string(&resolved).map(|value| Prioritized { value, priority }); + } + bool_from_config_string(text).map(|value| Prioritized { + value, + priority: base_priority, + }) +} + +fn prioritized_string_from_raw( + text: String, + agent_yaml: &str, + base_priority: ConfigSourcePriority, +) -> Prioritized { + if base_priority != ConfigSourcePriority::FleetPolicies + && let Some((value, priority)) = promote_secret_string(&text, agent_yaml) + { + return Prioritized { value, priority }; + } + Prioritized { + value: text, + priority: base_priority, + } +} + +fn promote_secret_string( + text: &str, + agent_yaml: &str, +) -> Option<(String, ConfigSourcePriority)> { + if !secrets::is_enc(text) { + return None; + } + let resolved = secrets::resolve_config_string(text, agent_yaml); + if secrets::is_enc(&resolved) { + return None; + } + Some((resolved, ConfigSourcePriority::Secret)) +} + +fn value_as_bool(value: &serde_yaml::Value, agent_yaml: &str) -> Option { + prioritized_bool_from_yaml_value(value, agent_yaml, ConfigSourcePriority::File) + .map(|layer| layer.value) +} + +fn bool_from_config_string(text: &str) -> Option { + if secrets::is_enc(text) { + return None; + } + parse_agent_bool_string(text).or(Some(false)) +} + /// Mirrors Go `strconv.ParseBool` for env var bindings. pub(super) fn parse_agent_bool_string(text: &str) -> Option { match text { @@ -529,20 +717,67 @@ pub fn condition_config_summary(conditions: &[ConditionConfigFile]) -> String { .iter() .flat_map(|file| { let path = expand_env_vars(&file.path); - file.keys.iter().map(move |key| format!("{path}:{key}")) + file.keys + .iter() + .map(move |key| format!("{path}:{key}")) }) .collect::>() .join(", ") } +#[cfg(test)] +pub(crate) mod test_env { + use std::sync::Mutex; + + static LOCK: Mutex<()> = Mutex::new(()); + + /// Serialize tests that mutate process environment (config gates + secret backend). + pub(crate) fn with_lock(test: F) { + let _guard = LOCK.lock().unwrap_or_else(|err| err.into_inner()); + test(); + } + + /// Clear `DD_SECRET_BACKEND_*` overrides so parallel tests cannot hijack backend resolution. + pub(crate) fn clear_secret_backend_env_vars() { + const NAMES: &[&str] = &[ + "DD_SECRET_BACKEND_COMMAND", + "DD_SECRET_BACKEND_ARGUMENTS", + "DD_SECRET_BACKEND_TYPE", + "DD_SECRET_BACKEND_CONFIG", + "DD_SECRET_BACKEND_TIMEOUT", + "DD_SECRET_BACKEND_OUTPUT_MAX_SIZE", + "DD_SECRET_BACKEND_REMOVE_TRAILING_LINE_BREAK", + ]; + for name in NAMES { + // SAFETY: callers must hold the test env lock. + unsafe { std::env::remove_var(name) }; + } + } + + /// `tempfile` directories are mode `0700`; on Linux CI (root) secret backends run as `dd-agent`. + #[cfg(unix)] + pub(crate) fn open_tempdir_for_agent_user(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("open tempdir for agent user"); + } + + #[cfg(not(unix))] + pub(crate) fn open_tempdir_for_agent_user(_path: &std::path::Path) {} + + pub(crate) fn tempdir_for_secret_backend() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + open_tempdir_for_agent_user(dir.path()); + dir + } +} + #[cfg(test)] mod tests { + use super::test_env; use super::*; use std::io::Write; use std::path::Path; - use std::sync::Mutex; - - static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); fn write_config(dir: &Path, name: &str, body: &str) -> String { let path = dir.join(name); @@ -599,15 +834,18 @@ process_config: } fn with_env_lock(test: F) { - let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - test(); + test_env::with_lock(|| { + test_env::clear_secret_backend_env_vars(); + test(); + }); } fn clear_gated_env_vars() { - // SAFETY: callers must hold ENV_TEST_LOCK. + // SAFETY: callers must hold the test env lock. unsafe { std::env::remove_var("DD_FLEET_POLICIES_DIR") }; + test_env::clear_secret_backend_env_vars(); for env_name in super::env_bindings::all_bound_env_var_names() { - // SAFETY: callers must hold ENV_TEST_LOCK. + // SAFETY: callers must hold the test env lock. unsafe { std::env::remove_var(env_name) }; } } @@ -881,7 +1119,10 @@ process_config: let _empty = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", ""); assert_eq!( - env_bool_for_config_key("process_config.process_discovery.enabled"), + env_bool_for_config_key( + "process_config.process_discovery.enabled", + "/nonexistent/datadog.yaml", + ), None ); assert!(!env_configured_for_key( @@ -898,7 +1139,10 @@ process_config: let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_DISCOVERY_ENABLED", "true"); assert_eq!( - env_bool_for_config_key("process_config.process_discovery.enabled"), + env_bool_for_config_key( + "process_config.process_discovery.enabled", + "/nonexistent/datadog.yaml", + ), Some(true) ); assert!(env_configured_for_key( @@ -957,7 +1201,10 @@ process_config: let _process = EnvGuard::set("DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", "true"); assert_eq!( - env_bool_for_config_key("process_config.container_collection.enabled"), + env_bool_for_config_key( + "process_config.container_collection.enabled", + "/nonexistent/datadog.yaml", + ), Some(false) ); }); @@ -1129,6 +1376,62 @@ process_config: }); } + #[test] + fn fleet_policies_dir_resolves_secret_backed_env_path() { + with_env_lock(|| { + clear_gated_env_vars(); + secrets::clear_caches(); + + let dir = test_env::tempdir_for_secret_backend(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: true\n", + ); + let fleet_dir_json = + serde_json::to_string(fleet_dir.to_string_lossy().as_ref()).unwrap(); + #[cfg(unix)] + let script = { + let path = dir.path().join("secret_backend.sh"); + std::fs::write( + &path, + format!( + "#!/bin/sh\nprintf '{{\"fleet_policies_dir\":{{\"value\":{fleet_dir_json}}}}}'\n" + ), + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("secret_backend.cmd"); + std::fs::write( + &path, + format!( + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{{\\\"fleet_policies_dir\\\":{{\\\"value\\\":{fleet_dir_json}}}}}'\"\r\n" + ), + ) + .unwrap(); + path + }; + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!( + "secret_backend_command: {}\nprocess_config:\n enabled: false\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + script.to_string_lossy() + ), + ); + let _fleet = EnvGuard::set("DD_FLEET_POLICIES_DIR", "ENC[fleet_policies_dir]"); + + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + #[test] fn fleet_policies_dir_from_system_probe_yaml_enables_gate() { with_env_lock(|| { @@ -1553,7 +1856,115 @@ process_config: } #[test] - fn fleet_legacy_beats_env_for_process_enabled_transform() { + fn secret_resolved_value_beats_fleet_policy() { + with_env_lock(|| { + clear_gated_env_vars(); + secrets::clear_caches(); + + let dir = test_env::tempdir_for_secret_backend(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + #[cfg(unix)] + let script = { + let path = dir.path().join("secret_backend.sh"); + std::fs::write( + &path, + "#!/bin/sh\nprintf '{\"process_collection_enabled\":{\"value\":\"true\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"process_collection_enabled\\\":{\\\"value\\\":\\\"true\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!( + "secret_backend_command: {}\nprocess_config:\n enabled: false\n process_collection:\n enabled: ENC[process_collection_enabled]\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + script.to_string_lossy() + ), + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policy_enc_is_not_resolved() { + with_env_lock(|| { + clear_gated_env_vars(); + secrets::clear_caches(); + + let dir = test_env::tempdir_for_secret_backend(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: ENC[fleet_collection_enabled]\n process_discovery:\n enabled: false\n", + ); + #[cfg(unix)] + let script = { + let path = dir.path().join("secret_backend.sh"); + std::fs::write( + &path, + "#!/bin/sh\nprintf '{\"fleet_collection_enabled\":{\"value\":\"true\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"fleet_collection_enabled\\\":{\\\"value\\\":\\\"true\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!( + "secret_backend_command: {}\n{}", + script.to_string_lossy(), + ALL_PROCESS_GATES_OFF + ), + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!( + !condition_config_any_met(&process_agent_conditions(agent)), + "fleet policy ENC handles must stay unresolved like Agent MergeFleetPolicy" + ); + }); + } + + #[test] + fn fleet_legacy_enabled_does_not_override_base_transform() { with_env_lock(|| { clear_gated_env_vars(); @@ -1563,18 +1974,17 @@ process_config: write_config( &fleet_dir, "datadog.yaml", - "process_config:\n enabled: true\n", + "process_config:\n enabled: disabled\n process_discovery:\n enabled: false\n", ); let agent = write_config( dir.path(), "datadog.yaml", - "process_config:\n process_discovery:\n enabled: false\n", + "process_config:\n enabled: true\n process_discovery:\n enabled: false\n", ); let _fleet = EnvGuard::set( "DD_FLEET_POLICIES_DIR", fleet_dir.to_string_lossy().as_ref(), ); - let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", "false"); let conditions = vec![ConditionConfigFile { path: agent, keys: vec!["process_config.process_collection.enabled".into()], @@ -1583,6 +1993,42 @@ process_config: }); } + #[test] + fn legacy_env_beats_fleet_for_process_enabled_transform() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: false\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", "false"); + let process_collection = vec![ConditionConfigFile { + path: agent.clone(), + keys: vec!["process_config.process_collection.enabled".into()], + }]; + let container_collection = vec![ConditionConfigFile { + path: agent, + keys: vec!["process_config.container_collection.enabled".into()], + }]; + assert!(!condition_config_any_met(&process_collection)); + assert!(condition_config_any_met(&container_collection)); + }); + } + #[test] fn yaml_cache_reads_each_path_once() { let dir = tempfile::tempdir().unwrap(); @@ -1611,6 +2057,25 @@ process_config: assert!(!condition_config_any_met(&conditions)); } + #[test] + fn env_bool_unresolved_secret_falls_through_instead_of_false() { + with_env_lock(|| { + clear_gated_env_vars(); + let _enc = EnvGuard::set( + "DD_PROCESS_CONFIG_PROCESS_COLLECTION_ENABLED", + "ENC[missing_backend]", + ); + + assert_eq!( + env_bool_for_config_key( + "process_config.process_collection.enabled", + "/nonexistent/datadog.yaml", + ), + None + ); + }); + } + #[test] fn parse_agent_bool_string_matches_strconv_parse_bool() { for (input, expected) in [ @@ -1668,25 +2133,54 @@ process_config: }); } + #[test] + fn value_as_bool_handles_numeric_scalars() { + let agent_yaml = "/nonexistent/datadog.yaml"; + assert_eq!( + value_as_bool(&serde_yaml::Value::Number(1.into()), agent_yaml), + Some(true) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::Number(0.into()), agent_yaml), + Some(false) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::Number(1.0.into()), agent_yaml), + Some(true) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::Number(0.0.into()), agent_yaml), + Some(false) + ); + } + #[test] fn value_as_bool_handles_strings() { + let agent_yaml = "/nonexistent/datadog.yaml"; assert_eq!( - value_as_bool(&serde_yaml::Value::String("disabled".into())), + value_as_bool(&serde_yaml::Value::String("disabled".into()), agent_yaml), Some(false) ); assert_eq!( - value_as_bool(&serde_yaml::Value::String("true".into())), + value_as_bool(&serde_yaml::Value::String("true".into()), agent_yaml), + Some(true) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::String("TRUE".into()), agent_yaml), Some(true) ); assert_eq!( - value_as_bool(&serde_yaml::Value::String("1".into())), + value_as_bool(&serde_yaml::Value::String("1".into()), agent_yaml), Some(true) ); assert_eq!( - value_as_bool(&serde_yaml::Value::String("yes".into())), + value_as_bool(&serde_yaml::Value::String("yes".into()), agent_yaml), Some(false) ); - assert_eq!(value_as_bool(&serde_yaml::Value::Bool(true)), Some(true)); + assert_eq!( + value_as_bool(&serde_yaml::Value::Bool(true), agent_yaml), + Some(true) + ); } #[test] @@ -1727,6 +2221,51 @@ process_config: }); } + #[test] + fn env_bool_resolves_secret_backed_gate_values() { + with_env_lock(|| { + clear_gated_env_vars(); + secrets::clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = dir.path().join("secret_backend.sh"); + #[cfg(unix)] + { + std::fs::write( + &script, + "#!/bin/sh\nprintf '{\"process_enabled\":{\"value\":\"true\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(windows)] + let script = { + let path = dir.path().join("secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"process_enabled\\\":{\\\"value\\\":\\\"true\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!("secret_backend_command: {}\n", script.to_string_lossy()), + ); + let _enc = EnvGuard::set( + "DD_PROCESS_CONFIG_PROCESS_COLLECTION_ENABLED", + "ENC[process_enabled]", + ); + + assert_eq!( + env_bool_for_config_key("process_config.process_collection.enabled", &agent,), + Some(true) + ); + }); + } + #[test] fn env_yes_does_not_enable_gate() { with_env_lock(|| { @@ -1980,9 +2519,9 @@ process_config: let dir = tempfile::tempdir().unwrap(); let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); - assert!(condition_config_any_met(&process_agent_windows_conditions( - agent, sysprobe - ))); + assert!(condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); }); } @@ -2040,6 +2579,49 @@ process_config: }); } + #[test] + fn derived_secret_infrastructure_mode_enables_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + secrets::clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = dir.path().join("secret_backend.sh"); + #[cfg(unix)] + { + std::fs::write( + &script, + "#!/bin/sh\nprintf '{\"eudm\":{\"value\":\"end_user_device\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(windows)] + let script = { + let path = dir.path().join("secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"eudm\\\":{\\\"value\\\":\\\"end_user_device\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!( + "secret_backend_command: {}\nprocess_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\ninfrastructure_mode: ENC[eudm]\n", + script.to_string_lossy() + ), + ); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + #[test] fn derived_usm_env_enables_system_probe_gate() { with_env_lock(|| { diff --git a/pkg/procmgr/rust/src/config_gate/env_bindings.rs b/pkg/procmgr/rust/src/config_gate/env_bindings.rs index 9af8ddef3b42..371c4f1e939b 100644 --- a/pkg/procmgr/rust/src/config_gate/env_bindings.rs +++ b/pkg/procmgr/rust/src/config_gate/env_bindings.rs @@ -18,6 +18,8 @@ //! registry first, then dd-procmgr's process environment, so service-local //! overrides match agent config resolution. +use super::secrets; + struct EnvBinding { key: &'static str, env_vars: &'static [&'static str], @@ -101,13 +103,13 @@ pub(super) fn env_vars_for_key(key: &str) -> &'static [&'static str] { .unwrap_or(&[]) } -pub(super) fn env_bool_for_config_key(key: &str) -> Option { +pub(super) fn env_bool_for_config_key(key: &str, agent_yaml: &str) -> Option { let names = env_vars_for_key(key); if !names.is_empty() { - return env_bool_from_names(names); + return env_bool_from_names(names, agent_yaml); } let auto = auto_env_var_for_key(key); - env_bool_from_names(&[&auto]) + env_bool_from_names(&[&auto], agent_yaml) } /// Whether any env var bound to `key` is set to a non-empty value (mirrors Go `IsConfigured` env source). @@ -152,15 +154,26 @@ fn env_var_nonempty(name: &str) -> bool { env_var_value(name).is_some() } -fn env_bool_from_names(names: &[&str]) -> Option { +fn env_bool_from_names(names: &[&str], agent_yaml: &str) -> Option { for name in names { if let Some(value) = env_var_value(name) { - return Some(super::parse_agent_bool_string(&value).unwrap_or(false)); + return bool_from_env_string(&value, agent_yaml); } } None } +fn bool_from_env_string(value: &str, agent_yaml: &str) -> Option { + let resolved = secrets::resolve_config_string(value, agent_yaml); + if let Some(enabled) = super::parse_agent_bool_string(&resolved) { + return Some(enabled); + } + if secrets::is_enc(value) { + return None; + } + Some(super::parse_agent_bool_string(value).unwrap_or(false)) +} + /// Core Agent SCM `Environment` overrides, then dd-procmgr process env. /// SCM wins when present so config gates match agent service-local resolution. fn env_var_value(name: &str) -> Option { diff --git a/pkg/procmgr/rust/src/config_gate/secrets.rs b/pkg/procmgr/rust/src/config_gate/secrets.rs new file mode 100644 index 000000000000..88420c74d59e --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/secrets.rs @@ -0,0 +1,987 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Resolve `ENC[...]` config values via the Agent secret backend. +//! +//! Mirrors `comp/core/secrets/utils.IsEnc` and `fetch_secret.go`. Supports +//! `secret_backend_command`, `secret_backend_type`, and `multi_secret_backends` +//! with the same precedence as the core Agent (command > type > multi), invoking +//! `secret-generic-connector` for native backends. +//! +//! Backend settings follow Agent config precedence: `DD_SECRET_BACKEND_*` env +//! vars (including the core Agent service SCM `Environment` on Windows) override +//! `datadog.yaml`. The backend command always runs as the core Agent service +//! account (`dd-agent` / `datadogagent`), not as the procmgr supervisor or a +//! Privileged managed-child identity. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use log::debug; +use serde_json::Value; + +use super::env_bindings; +use super::yaml_load; + +const PAYLOAD_VERSION: &str = "1.1"; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const DEFAULT_MAX_OUTPUT_BYTES: usize = 1_048_576; + +#[derive(Clone)] +struct MultiBackendEntry { + backend_type: String, + config: Option, +} + +#[derive(Clone)] +struct Backend { + command: String, + arguments: Vec, + skip_acl_check: bool, + multi_backends: Option>, + global_backend_type: Option, + global_backend_config: Option, + timeout_secs: u64, + max_output_bytes: usize, + remove_trailing_line_break: bool, +} + +static HANDLE_CACHE: OnceLock>> = OnceLock::new(); +static BACKEND_CACHE: OnceLock>>> = OnceLock::new(); + +pub(super) fn is_enc(value: &str) -> bool { + enc_handle(value).is_some() +} + +/// Resolve `ENC[handle]` through the Agent secret backend; return `raw` unchanged otherwise. +pub(super) fn resolve_config_string(raw: &str, agent_yaml: &str) -> String { + let Some(handle) = enc_handle(raw) else { + return raw.to_owned(); + }; + match resolve_handle(&handle, agent_yaml) { + Ok(value) => value, + Err(err) => { + debug!("config gate secret resolve failed for {handle}: {err:#}"); + raw.to_owned() + } + } +} + +fn enc_handle(value: &str) -> Option { + let trimmed = value.trim(); + let inner = trimmed.strip_prefix("ENC[")?.strip_suffix(']')?; + Some(inner.to_owned()) +} + +fn resolve_handle(handle: &str, agent_yaml: &str) -> Result { + if let Some(cached) = cached_handle(handle) { + return Ok(cached); + } + let Some(backend) = backend_for(agent_yaml)? else { + bail!("no secret backend is configured"); + }; + let (backend_type, backend_config, secret_key) = route_handle(&backend, handle)?; + let value = fetch_secret( + &backend, + &secret_key, + backend_type.as_deref(), + backend_config.as_ref(), + )?; + cache_handle(handle, &value); + Ok(value) +} + +fn route_handle( + backend: &Backend, + handle: &str, +) -> Result<(Option, Option, String)> { + if backend.multi_backends.is_some() { + let (backend_id, secret_key) = split_secret_handle(handle); + if backend_id.is_empty() { + if backend.global_backend_type.is_none() { + bail!("unknown backend"); + } + return Ok(( + backend.global_backend_type.clone(), + backend.global_backend_config.clone(), + secret_key.to_owned(), + )); + } + let entry = backend + .multi_backends + .as_ref() + .and_then(|backends| backends.get(&backend_id.to_ascii_lowercase())) + .with_context(|| format!("unknown backend {backend_id:?}"))?; + return Ok(( + Some(entry.backend_type.clone()), + entry.config.clone(), + secret_key.to_owned(), + )); + } + Ok(( + backend.global_backend_type.clone(), + backend.global_backend_config.clone(), + handle.to_owned(), + )) +} + +fn split_secret_handle(handle: &str) -> (&str, &str) { + match handle.split_once(';') { + Some((backend_id, secret_key)) => (backend_id, secret_key), + None => ("", handle), + } +} + +fn cached_handle(handle: &str) -> Option { + HANDLE_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .ok()? + .get(handle) + .cloned() +} + +fn cache_handle(handle: &str, value: &str) { + if let Ok(mut cache) = HANDLE_CACHE + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + { + cache.insert(handle.to_owned(), value.to_owned()); + } +} + +/// Drop cached handles and backend settings so reload re-queries the secret backend. +pub(super) fn clear_caches() { + if let Some(cache) = HANDLE_CACHE.get() + && let Ok(mut guard) = cache.lock() + { + guard.clear(); + } + if let Some(cache) = BACKEND_CACHE.get() + && let Ok(mut guard) = cache.lock() + { + guard.clear(); + } +} + +fn backend_for(agent_yaml: &str) -> Result> { + let cache = BACKEND_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = cache.lock().expect("backend cache lock"); + if let Some(backend) = guard.get(agent_yaml) { + return Ok(backend.clone()); + } + let backend = load_backend(agent_yaml)?; + guard.insert(agent_yaml.to_owned(), backend.clone()); + Ok(backend) +} + +fn load_backend(agent_yaml: &str) -> Result> { + let root = load_agent_yaml_root(agent_yaml)?; + let settings = common_backend_settings(root.as_ref())?; + + if let Some(command) = load_command(root.as_ref()) { + return Ok(Some(Backend { + command, + arguments: load_arguments(root.as_ref()), + skip_acl_check: false, + multi_backends: None, + global_backend_type: None, + global_backend_config: None, + ..settings + })); + } + + if let Some(backend_type) = load_backend_type(root.as_ref()) { + return Ok(Some(embedded_backend( + settings, + Some(backend_type), + load_backend_config(root.as_ref()), + None, + ))); + } + + if let Some(multi) = load_multi_backends(root.as_ref()) { + return Ok(Some(embedded_backend(settings, None, None, Some(multi)))); + } + + Ok(None) +} + +fn embedded_backend( + settings: Backend, + global_backend_type: Option, + global_backend_config: Option, + multi_backends: Option>, +) -> Backend { + Backend { + command: crate::platform::embedded_secret_connector_path() + .to_string_lossy() + .into_owned(), + arguments: settings.arguments, + skip_acl_check: true, + multi_backends, + global_backend_type, + global_backend_config, + timeout_secs: settings.timeout_secs, + max_output_bytes: settings.max_output_bytes, + remove_trailing_line_break: settings.remove_trailing_line_break, + } +} + +fn common_backend_settings(root: Option<&serde_yaml::Value>) -> Result { + Ok(Backend { + command: String::new(), + arguments: load_arguments(root), + skip_acl_check: false, + multi_backends: None, + global_backend_type: None, + global_backend_config: None, + timeout_secs: env_u64("DD_SECRET_BACKEND_TIMEOUT") + .or_else(|| root.and_then(|yaml| yaml_u64(yaml, "secret_backend_timeout"))) + .unwrap_or(DEFAULT_TIMEOUT_SECS), + max_output_bytes: env_usize("DD_SECRET_BACKEND_OUTPUT_MAX_SIZE") + .or_else(|| root.and_then(|yaml| yaml_usize(yaml, "secret_backend_output_max_size"))) + .unwrap_or(DEFAULT_MAX_OUTPUT_BYTES), + remove_trailing_line_break: env_bool("DD_SECRET_BACKEND_REMOVE_TRAILING_LINE_BREAK") + .or_else(|| { + root.and_then(|yaml| yaml_bool(yaml, "secret_backend_remove_trailing_line_break")) + }) + .unwrap_or(false), + }) +} + +fn load_command(root: Option<&serde_yaml::Value>) -> Option { + env_string("DD_SECRET_BACKEND_COMMAND") + .or_else(|| root.and_then(|yaml| yaml_string(yaml, "secret_backend_command"))) + .filter(|command| !command.trim().is_empty()) +} + +fn load_backend_type(root: Option<&serde_yaml::Value>) -> Option { + env_string("DD_SECRET_BACKEND_TYPE") + .or_else(|| root.and_then(|yaml| yaml_string(yaml, "secret_backend_type"))) + .filter(|backend_type| !backend_type.trim().is_empty()) +} + +fn load_backend_config(root: Option<&serde_yaml::Value>) -> Option { + match env_string("DD_SECRET_BACKEND_CONFIG") { + Some(text) => parse_env_json_map(&text), + None => root + .and_then(|yaml| yaml_get(yaml, "secret_backend_config")) + .and_then(|value| serde_json::to_value(value).ok()), + } + .filter(|value| !value.is_null()) +} + +fn parse_env_json_map(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + match serde_json::from_str::(trimmed) { + Ok(value) if !value.is_null() => Some(value), + Ok(_) => None, + Err(err) => { + debug!("ignore invalid JSON in DD_SECRET_BACKEND_CONFIG: {err}"); + None + } + } +} + +fn load_multi_backends( + root: Option<&serde_yaml::Value>, +) -> Option> { + let mapping = yaml_get(root?, "multi_secret_backends")?.as_mapping()?; + let mut backends = HashMap::new(); + for (name, entry) in mapping { + let Some(name) = name.as_str() else { + continue; + }; + let Some(entry) = entry.as_mapping() else { + continue; + }; + let backend_type = super::lookup_mapping_case_insensitive(entry, "type") + .and_then(|value| value.as_str()) + .filter(|text| !text.is_empty())?; + let config = super::lookup_mapping_case_insensitive(entry, "config") + .and_then(|value| serde_json::to_value(value).ok()); + backends.insert( + name.to_ascii_lowercase(), + MultiBackendEntry { + backend_type: backend_type.to_owned(), + config, + }, + ); + } + (!backends.is_empty()).then_some(backends) +} + +fn load_arguments(root: Option<&serde_yaml::Value>) -> Vec { + env_string_list("DD_SECRET_BACKEND_ARGUMENTS") + .or_else(|| root.map(|yaml| yaml_string_list(yaml, "secret_backend_arguments"))) + .unwrap_or_default() +} + +fn load_agent_yaml_root(agent_yaml: &str) -> Result> { + if !Path::new(agent_yaml).is_file() { + return Ok(None); + } + let contents = std::fs::read_to_string(agent_yaml) + .with_context(|| format!("read {agent_yaml} for secret backend config"))?; + let root = yaml_load::load_yaml(&contents) + .with_context(|| format!("parse {agent_yaml} for secret backend config"))?; + Ok(Some(root)) +} + +fn env_string(name: &str) -> Option { + env_bindings::env_var_value_for_name(name) +} + +fn env_u64(name: &str) -> Option { + env_string(name).and_then(|text| text.parse().ok()) +} + +fn env_bool(name: &str) -> Option { + env_string(name) + .as_deref() + .and_then(super::parse_agent_bool_string) +} + +fn env_usize(name: &str) -> Option { + env_u64(name).and_then(|value| usize::try_from(value).ok()) +} + +fn env_string_list(name: &str) -> Option> { + env_string(name).map(|text| { + if text.is_empty() { + Vec::new() + } else { + text.split(' ').map(str::to_owned).collect() + } + }) +} + +fn fetch_secret( + backend: &Backend, + secret_key: &str, + backend_type: Option<&str>, + backend_config: Option<&Value>, +) -> Result { + let mut payload = serde_json::json!({ + "version": PAYLOAD_VERSION, + "secrets": [secret_key], + "secret_backend_timeout": backend.timeout_secs, + }); + if let Some(backend_type) = backend_type.filter(|text| !text.is_empty()) { + payload["type"] = Value::String(backend_type.to_owned()); + } + if let Some(config) = backend_config.filter(|value| !value.is_null()) { + payload["config"] = config.clone(); + } + let output = exec_backend(backend, &payload.to_string())?; + parse_secret_response(&output, secret_key, backend.remove_trailing_line_break) +} + +fn exec_backend(backend: &Backend, payload: &str) -> Result { + crate::platform::exec_secret_backend( + &backend.command, + &backend.arguments, + payload, + Duration::from_secs(backend.timeout_secs), + backend.max_output_bytes, + backend.skip_acl_check, + ) +} + +fn parse_secret_response( + output: &str, + handle: &str, + remove_trailing_line_break: bool, +) -> Result { + let parsed: Value = + serde_json::from_str(output).context("parse secret backend JSON response")?; + let Some(entry) = parsed.get(handle) else { + bail!("secret backend response missing handle {handle}"); + }; + if let Some(error) = entry.get("error").and_then(Value::as_str) + && !error.is_empty() + { + bail!("secret backend error for {handle}: {error}"); + } + entry + .get("value") + .and_then(Value::as_str) + .map(|value| normalize_secret_value(value, remove_trailing_line_break)) + .with_context(|| format!("secret backend response missing value for {handle}")) +} + +fn normalize_secret_value(value: &str, remove_trailing_line_break: bool) -> String { + if remove_trailing_line_break { + value.trim_end_matches(['\r', '\n']).to_string() + } else { + value.to_owned() + } +} + +fn yaml_get<'a>(root: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { + let mapping = root.as_mapping()?; + super::lookup_mapping_case_insensitive(mapping, key) +} + +fn yaml_string(root: &serde_yaml::Value, key: &str) -> Option { + yaml_get(root, key) + .and_then(|value| match value { + serde_yaml::Value::String(text) => Some(text.clone()), + serde_yaml::Value::Number(number) => Some(number.to_string()), + serde_yaml::Value::Bool(enabled) => Some(enabled.to_string()), + _ => None, + }) + .filter(|text| !text.is_empty()) +} + +fn yaml_string_list(root: &serde_yaml::Value, key: &str) -> Vec { + yaml_get(root, key) + .and_then(|value| value.as_sequence()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() +} + +fn yaml_u64(root: &serde_yaml::Value, key: &str) -> Option { + yaml_get(root, key).and_then(|value| match value { + serde_yaml::Value::Number(number) => number.as_u64(), + serde_yaml::Value::String(text) => text.parse().ok(), + _ => None, + }) +} + +fn yaml_usize(root: &serde_yaml::Value, key: &str) -> Option { + yaml_u64(root, key).and_then(|value| usize::try_from(value).ok()) +} + +fn yaml_bool(root: &serde_yaml::Value, key: &str) -> Option { + yaml_get(root, key).and_then(|value| match value { + serde_yaml::Value::Bool(enabled) => Some(*enabled), + serde_yaml::Value::Number(number) => number.as_f64().map(|n| n != 0.0), + serde_yaml::Value::String(text) => super::parse_agent_bool_string(text), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_gate::test_env; + + struct EnvGuard { + name: String, + } + + impl EnvGuard { + fn set(name: &str, value: &str) -> Self { + // SAFETY: tests acquire the env lock before calling set_var. + unsafe { std::env::set_var(name, value) }; + Self { + name: name.to_owned(), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: tests acquire the env lock before calling remove_var. + unsafe { std::env::remove_var(&self.name) }; + } + } + + fn with_env_lock(test: F) { + test_env::with_lock(|| { + test_env::clear_secret_backend_env_vars(); + test(); + }); + } + + #[test] + fn enc_handle_parses_trimmed_values() { + assert_eq!( + enc_handle("ENC[process_enabled]"), + Some("process_enabled".into()) + ); + assert_eq!( + enc_handle(" ENC[ process_enabled ] "), + Some(" process_enabled ".into()) + ); + assert_eq!(enc_handle("true"), None); + assert_eq!(enc_handle("ENC[]"), Some(String::new())); + assert_eq!(enc_handle("ENC[]]]]"), Some("]]]".into())); + } + + #[test] + fn split_secret_handle_splits_backend_id_and_key() { + assert_eq!( + split_secret_handle("file;process_enabled"), + ("file", "process_enabled") + ); + assert_eq!( + split_secret_handle("process_enabled"), + ("", "process_enabled") + ); + } + + #[test] + fn parse_secret_response_ignores_empty_error_field() { + let output = r#"{"process_enabled":{"value":"true","error":""}}"#; + assert_eq!( + parse_secret_response(output, "process_enabled", false).unwrap(), + "true" + ); + } + + #[test] + fn parse_secret_response_rejects_non_empty_error_field() { + let output = r#"{"process_enabled":{"value":"true","error":"backend failed"}}"#; + let err = parse_secret_response(output, "process_enabled", false).unwrap_err(); + assert!( + err.to_string().contains("backend failed"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn resolve_config_string_leaves_literals_unchanged() { + assert_eq!( + resolve_config_string("true", "/nonexistent/datadog.yaml"), + "true" + ); + } + + #[test] + fn resolve_config_string_uses_env_secret_backend_command() { + with_env_lock(|| { + clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = { + let path = dir.path().join("env_secret_backend.sh"); + std::fs::write( + &path, + "#!/bin/sh\nprintf '{\"process_enabled\":{\"value\":\"true\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("env_secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"process_enabled\\\":{\\\"value\\\":\\\"true\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write(&agent_yaml, "# no secret backend in yaml\n").unwrap(); + let _backend = EnvGuard::set( + "DD_SECRET_BACKEND_COMMAND", + script.to_string_lossy().as_ref(), + ); + + assert_eq!( + resolve_config_string("ENC[process_enabled]", agent_yaml.to_str().unwrap()), + "true" + ); + }); + } + + #[test] + fn load_backend_uses_mixed_case_secret_backend_keys() { + with_env_lock(|| { + clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = { + let path = dir.path().join("mixed_case_secret_backend.sh"); + std::fs::write( + &path, + "#!/bin/sh\nprintf '{\"process_enabled\":{\"value\":\"true\"}}'\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("mixed_case_secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"process_enabled\\\":{\\\"value\\\":\\\"true\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + format!("Secret_Backend_Command: {}\n", script.to_string_lossy()), + ) + .unwrap(); + + assert_eq!( + resolve_config_string("ENC[process_enabled]", agent_yaml.to_str().unwrap()), + "true" + ); + }); + } + + #[test] + fn load_backend_parses_mixed_case_multi_secret_backends() { + with_env_lock(|| { + clear_caches(); + let dir = tempfile::tempdir().unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + "Multi_Secret_Backends:\n file:\n Type: file.yaml\n Config:\n file_path: /tmp/secrets.yaml\n", + ) + .unwrap(); + + let backend = load_backend(agent_yaml.to_str().unwrap()) + .unwrap() + .expect("backend"); + let entry = backend + .multi_backends + .as_ref() + .and_then(|backends| backends.get("file")) + .expect("file backend"); + assert_eq!(entry.backend_type, "file.yaml"); + assert_eq!( + entry.config.as_ref().and_then(|c| c.get("file_path")), + Some(&Value::String("/tmp/secrets.yaml".into())) + ); + }); + } + + #[test] + fn load_backend_prefers_command_over_secret_backend_type() { + with_env_lock(|| { + clear_caches(); + let dir = tempfile::tempdir().unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + "secret_backend_type: file.json\nsecret_backend_command: /custom/backend\n", + ) + .unwrap(); + let _command = EnvGuard::set("DD_SECRET_BACKEND_COMMAND", "/env/backend"); + + let backend = load_backend(agent_yaml.to_str().unwrap()) + .unwrap() + .expect("backend"); + assert_eq!(backend.command, "/env/backend"); + assert!(!backend.skip_acl_check); + assert!(backend.global_backend_type.is_none()); + }); + } + + #[test] + fn load_backend_uses_embedded_connector_for_secret_backend_type() { + with_env_lock(|| { + clear_caches(); + let dir = tempfile::tempdir().unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + "secret_backend_type: file.json\nsecret_backend_config:\n file_path: /tmp/secrets.json\n", + ) + .unwrap(); + + let backend = load_backend(agent_yaml.to_str().unwrap()) + .unwrap() + .expect("backend"); + assert_eq!( + backend.command, + crate::platform::embedded_secret_connector_path() + .to_string_lossy() + .as_ref() + ); + assert!(backend.skip_acl_check); + assert_eq!(backend.global_backend_type.as_deref(), Some("file.json")); + assert_eq!( + backend + .global_backend_config + .as_ref() + .and_then(|c| c.get("file_path")), + Some(&Value::String("/tmp/secrets.json".into())) + ); + }); + } + + #[test] + fn load_backend_uses_env_secret_backend_config() { + let connector = crate::platform::embedded_secret_connector_path(); + if !connector.is_file() { + return; + } + + with_env_lock(|| { + clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + let secrets_file = dir.path().join("secrets.json"); + std::fs::write(&secrets_file, r#"{"process_enabled": "true"}"#).unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + "secret_backend_type: file.json\nsecret_backend_config:\n file_path: /nonexistent/secrets.json\n", + ) + .unwrap(); + let _backend_type = EnvGuard::set("DD_SECRET_BACKEND_TYPE", "file.json"); + let _backend_config = EnvGuard::set( + "DD_SECRET_BACKEND_CONFIG", + &format!(r#"{{"file_path":"{}"}}"#, secrets_file.to_string_lossy()), + ); + + let backend = load_backend(agent_yaml.to_str().unwrap()) + .unwrap() + .expect("backend"); + assert_eq!(backend.global_backend_type.as_deref(), Some("file.json")); + assert_eq!( + backend + .global_backend_config + .as_ref() + .and_then(|c| c.get("file_path")), + Some(&Value::String(secrets_file.to_string_lossy().into_owned())) + ); + assert_eq!( + resolve_config_string("ENC[process_enabled]", agent_yaml.to_str().unwrap()), + "true" + ); + }); + } + + #[test] + fn load_backend_parses_multi_secret_backends() { + with_env_lock(|| { + clear_caches(); + let dir = tempfile::tempdir().unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + "multi_secret_backends:\n file:\n type: file.yaml\n config:\n file_path: /tmp/secrets.yaml\n", + ) + .unwrap(); + + let backend = load_backend(agent_yaml.to_str().unwrap()) + .unwrap() + .expect("backend"); + assert_eq!( + backend.command, + crate::platform::embedded_secret_connector_path() + .to_string_lossy() + .as_ref() + ); + assert!(backend.skip_acl_check); + let entry = backend + .multi_backends + .as_ref() + .and_then(|backends| backends.get("file")) + .expect("file backend"); + assert_eq!(entry.backend_type, "file.yaml"); + assert_eq!( + entry.config.as_ref().and_then(|c| c.get("file_path")), + Some(&Value::String("/tmp/secrets.yaml".into())) + ); + }); + } + + #[test] + fn resolve_config_string_uses_native_file_json_backend() { + let connector = crate::platform::embedded_secret_connector_path(); + if !connector.is_file() { + return; + } + + with_env_lock(|| { + clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + let secrets_file = dir.path().join("secrets.json"); + std::fs::write(&secrets_file, r#"{"process_enabled": "true"}"#).unwrap(); + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + format!( + "secret_backend_type: file.json\nsecret_backend_config:\n file_path: {}\n", + secrets_file.to_string_lossy() + ), + ) + .unwrap(); + + assert_eq!( + resolve_config_string("ENC[process_enabled]", agent_yaml.to_str().unwrap()), + "true" + ); + }); + } + + #[test] + fn route_handle_resolves_named_multi_backend_entry() { + let backend = Backend { + command: "ignored".into(), + arguments: Vec::new(), + skip_acl_check: true, + multi_backends: Some(HashMap::from([( + "file".to_string(), + MultiBackendEntry { + backend_type: "file.yaml".into(), + config: Some(serde_json::json!({"file_path": "/tmp/secrets.yaml"})), + }, + )])), + global_backend_type: None, + global_backend_config: None, + timeout_secs: DEFAULT_TIMEOUT_SECS, + max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, + remove_trailing_line_break: false, + }; + + let (backend_type, backend_config, secret_key) = + route_handle(&backend, "file;process_enabled").unwrap(); + assert_eq!(backend_type.as_deref(), Some("file.yaml")); + assert_eq!(secret_key, "process_enabled"); + assert_eq!( + backend_config.as_ref().and_then(|c| c.get("file_path")), + Some(&Value::String("/tmp/secrets.yaml".into())) + ); + } + + #[test] + fn route_handle_rejects_unprefixed_handle_for_multi_only_backends() { + let backend = Backend { + command: "ignored".into(), + arguments: Vec::new(), + skip_acl_check: true, + multi_backends: Some(HashMap::from([( + "file".to_string(), + MultiBackendEntry { + backend_type: "file.yaml".into(), + config: None, + }, + )])), + global_backend_type: None, + global_backend_config: None, + timeout_secs: DEFAULT_TIMEOUT_SECS, + max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, + remove_trailing_line_break: false, + }; + + assert!(route_handle(&backend, "process_enabled").is_err()); + } + + #[test] + fn normalize_secret_value_strips_trailing_line_breaks_when_enabled() { + assert_eq!(normalize_secret_value("true\r\n", true), "true".to_string()); + assert_eq!(normalize_secret_value("true\n", true), "true".to_string()); + assert_eq!( + normalize_secret_value("true\r\n", false), + "true\r\n".to_string() + ); + } + + #[test] + fn remove_trailing_line_break_honors_agent_bool_env_spelling() { + with_env_lock(|| { + clear_caches(); + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = { + let path = dir.path().join("newline_secret_backend.sh"); + std::fs::write( + &path, + "#!/bin/sh\ncat <<'EOF'\n{\"process_enabled\":{\"value\":\"true\\n\"}}\nEOF\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("newline_secret_backend.cmd"); + std::fs::write( + &path, + "@echo off\r\npowershell -NoProfile -Command \"Write-Output '{\\\"process_enabled\\\":{\\\"value\\\":\\\"true`n\\\"}}'\"\r\n", + ) + .unwrap(); + path + }; + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write(&agent_yaml, "# no secret backend in yaml\n").unwrap(); + let _command = EnvGuard::set( + "DD_SECRET_BACKEND_COMMAND", + script.to_string_lossy().as_ref(), + ); + let _strip = EnvGuard::set("DD_SECRET_BACKEND_REMOVE_TRAILING_LINE_BREAK", "1"); + + assert_eq!( + resolve_config_string("ENC[process_enabled]", agent_yaml.to_str().unwrap()), + "true" + ); + }); + } + + #[test] + fn clear_caches_drops_cached_handles() { + cache_handle("process_enabled", "true"); + assert_eq!(cached_handle("process_enabled"), Some("true".into())); + clear_caches(); + assert_eq!(cached_handle("process_enabled"), None); + } + + #[test] + fn exec_backend_times_out_hung_command() { + let dir = test_env::tempdir_for_secret_backend(); + #[cfg(unix)] + let script = { + let path = dir.path().join("slow_secret_backend.sh"); + std::fs::write(&path, "#!/bin/sh\nsleep 30\n").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + #[cfg(windows)] + let script = { + let path = dir.path().join("slow_secret_backend.cmd"); + std::fs::write(&path, "@echo off\r\nping -n 30 127.0.0.1 >nul\r\n").unwrap(); + path + }; + let agent_yaml = dir.path().join("datadog.yaml"); + std::fs::write( + &agent_yaml, + format!( + "secret_backend_command: {}\nsecret_backend_timeout: 1\n", + script.to_string_lossy() + ), + ) + .unwrap(); + + let started = std::time::Instant::now(); + assert_eq!( + resolve_config_string("ENC[slow]", agent_yaml.to_str().unwrap()), + "ENC[slow]" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "hung secret backend should time out quickly" + ); + } +} diff --git a/pkg/procmgr/rust/src/lib.rs b/pkg/procmgr/rust/src/lib.rs index 3db025364f04..04c7d4ec8aae 100644 --- a/pkg/procmgr/rust/src/lib.rs +++ b/pkg/procmgr/rust/src/lib.rs @@ -14,6 +14,7 @@ mod operation; pub mod ordering; pub mod platform; pub mod process; +mod secret_backend_exec; pub mod shutdown; mod spawn; mod spawn_context; diff --git a/pkg/procmgr/rust/src/platform/unix/mod.rs b/pkg/procmgr/rust/src/platform/unix/mod.rs index ef005bb31d7c..e0dc1d4ba67a 100644 --- a/pkg/procmgr/rust/src/platform/unix/mod.rs +++ b/pkg/procmgr/rust/src/platform/unix/mod.rs @@ -4,9 +4,16 @@ // Copyright 2026-present Datadog, Inc. mod runtime_user; +mod secret_backend; mod spawn; pub(crate) use runtime_user::runtime_user_for_pid; + +pub(crate) fn embedded_secret_connector_path() -> std::path::PathBuf { + std::path::PathBuf::from("/opt/datadog-agent/embedded/bin/secret-generic-connector") +} + +pub(crate) use secret_backend::exec_secret_backend; pub(crate) use spawn::spawn_child_handle; use nix::sys::signal::{self, Signal}; diff --git a/pkg/procmgr/rust/src/platform/unix/secret_backend.rs b/pkg/procmgr/rust/src/platform/unix/secret_backend.rs new file mode 100644 index 000000000000..0e3320fcc8d4 --- /dev/null +++ b/pkg/procmgr/rust/src/platform/unix/secret_backend.rs @@ -0,0 +1,101 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Run `secret_backend_command` under the core Agent service account on Unix. +//! +//! Secret resolution must match `datadog-agent`, not the procmgr supervisor identity. +//! Today procmgr runs as `dd-agent`, so the inherited token is sufficient. When Linux +//! [`SpawnProfile::Privileged`] children run under a host-privileged supervisor, the +//! backend is spawned with `setuid`/`setgid` to `dd-agent`. + +use std::os::unix::process::CommandExt; + +use anyhow::{Context, Result, bail}; +use nix::unistd::{Gid, Uid, User}; + +use crate::secret_backend_exec::{BackendRun, exec_inherited_token, spawn_and_capture}; + +/// Agent package user; matches systemd `User=` for `datadog-agent.service`. +const AGENT_USER: &str = "dd-agent"; +const PROCESS_NAME: &str = "secret-backend"; + +pub(crate) fn exec_secret_backend( + command: &str, + arguments: &[String], + payload: &str, + timeout: std::time::Duration, + max_output_bytes: usize, + _skip_acl_check: bool, +) -> Result { + let run = BackendRun { + command, + arguments, + payload, + timeout, + max_output_bytes, + }; + if supervisor_runs_as_agent_user()? { + return exec_inherited_token(&run); + } + if nix::unistd::getuid().is_root() { + return exec_as_agent_user(&run); + } + // Dev/CI: neither dd-agent nor root — best-effort inherited token. + log::debug!( + "[{PROCESS_NAME}] procmgr supervisor is not {AGENT_USER}; using inherited identity for secret backend" + ); + exec_inherited_token(&run) +} + +fn supervisor_runs_as_agent_user() -> Result { + let Some(agent) = User::from_name(AGENT_USER).context("lookup agent service user")? else { + return Ok(false); + }; + Ok(nix::unistd::getuid() == agent.uid) +} + +fn exec_as_agent_user(run: &BackendRun<'_>) -> Result { + let Some(agent) = User::from_name(AGENT_USER).context("lookup agent service user")? else { + bail!("agent service user {AGENT_USER} not found"); + }; + let uid = agent.uid; + let gid = agent.gid; + spawn_and_capture(run, |command| { + unsafe { + command.pre_exec(move || drop_to_agent_user(uid, gid)); + } + Ok(()) + }) +} + +unsafe fn drop_to_agent_user(uid: Uid, gid: Gid) -> std::io::Result<()> { + nix::unistd::setgid(gid).map_err(io_error)?; + nix::unistd::setuid(uid).map_err(io_error)?; + Ok(()) +} + +fn io_error(err: nix::errno::Errno) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("[{PROCESS_NAME}] {err}"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supervisor_identity_matches_agent_user_when_present() { + let is_agent = User::from_name(AGENT_USER) + .expect("lookup dd-agent") + .map(|agent| nix::unistd::getuid() == agent.uid) + .unwrap_or(false); + assert_eq!( + supervisor_runs_as_agent_user().expect("supervisor check"), + is_agent + ); + } +} diff --git a/pkg/procmgr/rust/src/platform/windows/mod.rs b/pkg/procmgr/rust/src/platform/windows/mod.rs index 1ed2a2997c59..0e37ba274bb2 100644 --- a/pkg/procmgr/rust/src/platform/windows/mod.rs +++ b/pkg/procmgr/rust/src/platform/windows/mod.rs @@ -14,6 +14,8 @@ mod pipe_security; mod resolve_executable; mod runtime_user; mod scm_service; +mod secret_backend; +mod secret_backend_rights; mod sid; mod spawn; mod wide; @@ -24,6 +26,13 @@ pub(crate) use pipe_caller::pipe_client_may_mutate; pub(crate) use pipe_security::create_pipe_server; pub(crate) use runtime_user::runtime_user_for_pid; pub use scm_service::run_as_service; +pub(crate) fn embedded_secret_connector_path() -> PathBuf { + install_root() + .join("bin") + .join("secret-generic-connector.exe") +} + +pub(crate) use secret_backend::exec_secret_backend; pub(crate) use spawn::spawn_child_handle; pub(crate) use spawn::user_profile::UserProfileGuard; diff --git a/pkg/procmgr/rust/src/platform/windows/secret_backend.rs b/pkg/procmgr/rust/src/platform/windows/secret_backend.rs new file mode 100644 index 000000000000..da5f8c2c5da8 --- /dev/null +++ b/pkg/procmgr/rust/src/platform/windows/secret_backend.rs @@ -0,0 +1,502 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Run `secret_backend_command` under the core Agent service account on Windows. +//! +//! Secret resolution must match `datadogagent`, not the dd-procmgr-service supervisor +//! (LocalSystem) or a [`SpawnProfile::Privileged`] child identity. Both spawn paths pass +//! the same merged environment (process baseline + core Agent SCM `Environment`) to +//! `CreateProcessAsUserW`, including when the Agent account is LocalSystem. + +use std::collections::HashMap; +use std::io::Write; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::FromRawHandle; +use std::ptr; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use windows_sys::Win32::Foundation::{ + CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, + WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_WRITE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY}; +use windows_sys::Win32::System::Threading::{ + CREATE_NEW_CONSOLE, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, + CreateProcessAsUserW, GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, + PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, TerminateProcess, + WaitForSingleObject, +}; + +use crate::secret_backend_exec::{BackendRun, wait_with_stdout_drain}; + +use super::agent_credentials::{AgentAccount, resolve_agent_account}; +use super::baseline_env_vars_from_token; +use super::legacy_scm_env::build_secret_backend_env_vars; +use super::resolve_executable::resolve_executable_in_env; +use super::secret_backend_rights; +use super::win_handle::WinHandle; +use super::spawn::logon::{TokenHandle, logon_user_credentials, logon_user_token}; +use super::spawn::user_profile::UserProfileGuard; +use super::spawn::win32::{build_windows_command_line, duplicate_primary_token, env_vars_to_wide_block}; +use super::wide; + +const PROCESS_NAME: &str = "secret-backend"; + +pub(crate) fn exec_secret_backend( + command: &str, + arguments: &[String], + payload: &str, + timeout: Duration, + max_output_bytes: usize, + skip_acl_check: bool, +) -> Result { + let account = + resolve_agent_account().context("resolve agent service account for secret backend")?; + let spawn = if supervisor_runs_as_agent_account(&account) { + let env = secret_backend_resolution_env(&account, None)?; + SecretBackendSpawn::Supervisor { + token: TokenHandle::new(supervisor_primary_token()?), + env, + } + } else { + let identity = AgentIdentity::load(&account)?; + let env = secret_backend_resolution_env(&account, Some(&identity))?; + SecretBackendSpawn::Agent { identity, env } + }; + + let env = spawn.env(); + let resolved_command = resolve_executable_in_env(command, env) + .with_context(|| format!("resolve secret backend executable {command}"))?; + validate_secret_backend_command(&resolved_command, skip_acl_check)?; + let run = BackendRun { + command: &resolved_command, + arguments, + payload, + timeout, + max_output_bytes, + }; + spawn.exec(&run) +} + +enum SecretBackendSpawn { + Supervisor { + token: TokenHandle, + env: HashMap, + }, + Agent { + identity: AgentIdentity, + env: HashMap, + }, +} + +impl SecretBackendSpawn { + fn env(&self) -> &HashMap { + match self { + Self::Supervisor { env, .. } | Self::Agent { env, .. } => env, + } + } + + fn exec(self, run: &BackendRun<'_>) -> Result { + match self { + Self::Supervisor { token, env } => exec_with_token_and_env(token, &env, run), + Self::Agent { identity, env } => exec_with_token_and_env(identity.token, &env, run), + } + } +} + +fn validate_secret_backend_command(resolved_command: &str, skip_acl_check: bool) -> Result<()> { + if skip_acl_check { + return Ok(()); + } + secret_backend_rights::check_secret_backend_command_rights(resolved_command) + .with_context(|| format!("validate secret backend executable {resolved_command}")) +} + +fn secret_backend_resolution_env( + account: &AgentAccount, + identity: Option<&AgentIdentity>, +) -> Result> { + let baseline = if account.inherits_supervisor_token() { + std::env::vars().collect() + } else { + let token = identity + .map(|loaded| loaded.token.raw()) + .context("agent identity required for secret backend PATH resolution")?; + baseline_env_vars_from_token(token)? + }; + Ok(build_secret_backend_env_vars(baseline)) +} + +fn supervisor_runs_as_agent_account(account: &AgentAccount) -> bool { + // dd-procmgr-service is LocalSystem; when datadogagent is too, inherited token matches. + account.inherits_supervisor_token() +} + +fn exec_with_token_and_env( + token: TokenHandle, + env: &HashMap, + run: &BackendRun<'_>, +) -> Result { + let child = spawn_with_pipes(token.raw(), run, env)?; + child.finish(run) +} + +fn supervisor_primary_token() -> Result { + let mut process_token: HANDLE = ptr::null_mut(); + let ok = unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE, + &mut process_token, + ) + }; + if ok == 0 { + bail!( + "[{PROCESS_NAME}] OpenProcessToken(GetCurrentProcess()) failed: {}", + std::io::Error::last_os_error() + ); + } + let process_token_guard = TokenHandle::new(process_token); + duplicate_primary_token(PROCESS_NAME, process_token_guard.raw()) +} + +struct AgentIdentity { + token: TokenHandle, + _profile: UserProfileGuard, +} + +impl AgentIdentity { + fn load(account: &AgentAccount) -> Result { + let creds = logon_user_credentials(account); + let logon_token = logon_user_token(PROCESS_NAME, &creds)?; + let primary = duplicate_primary_token(PROCESS_NAME, logon_token.raw())?; + let token = TokenHandle::new(primary); + let profile = UserProfileGuard::load(PROCESS_NAME, token.raw(), account)?; + Ok(Self { + token, + _profile: profile, + }) + } +} + +struct CapturedChild { + process: WinHandle, + stdin: std::fs::File, + stdout: std::fs::File, +} + +impl CapturedChild { + fn finish(mut self, run: &BackendRun<'_>) -> Result { + self.stdin + .write_all(run.payload.as_bytes()) + .context("write secret backend payload")?; + drop(self.stdin); + + let process = SendProcessHandle(self.process.as_handle()); + let stdout = self.stdout; + let command = run.command; + let timeout = run.timeout; + + wait_with_stdout_drain( + stdout, + run.max_output_bytes, + { + let process = process; + move || terminate_process(process.0) + }, + move || { + let exit_code = wait_for_exit(process.0, timeout, command)?; + if exit_code != 0 { + bail!("secret backend {command} exited with code {exit_code}"); + } + Ok(()) + }, + ) + } +} + +fn spawn_with_pipes( + token: HANDLE, + run: &BackendRun<'_>, + env: &HashMap, +) -> Result { + let stdin = Pipe::parent_writes()?; + let stdout = Pipe::parent_reads()?; + let stderr = nul_stderr_handle()?; + + let command_line = build_windows_command_line(run.command, run.arguments); + let mut command_line_w: Vec = std::ffi::OsStr::new(&command_line) + .encode_wide() + .chain([0]) + .collect(); + + let env_block = env_vars_to_wide_block(env); + let env_block_ptr = env_block.as_ptr() as *const std::ffi::c_void; + + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = stdin.child.raw(); + si.hStdOutput = stdout.child.raw(); + si.hStdError = stderr; + + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let ok = unsafe { + CreateProcessAsUserW( + token, + std::ptr::null(), + command_line_w.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + CREATE_NEW_PROCESS_GROUP + | CREATE_NEW_CONSOLE + | CREATE_NO_WINDOW + | CREATE_UNICODE_ENVIRONMENT, + env_block_ptr, + std::ptr::null(), + &si, + &mut pi, + ) + }; + + // Child ends are inherited by the process; close our copies. + drop(stdin.child); + drop(stdout.child); + drop(WinHandle::new(stderr)); + + if ok == 0 { + bail!( + "[{PROCESS_NAME}] CreateProcessAsUserW({}) failed: {}", + run.command, + std::io::Error::last_os_error() + ); + } + + unsafe { + CloseHandle(pi.hThread); + } + + Ok(CapturedChild { + process: WinHandle::new(pi.hProcess), + stdin: stdin.parent, + stdout: stdout.parent, + }) +} + +struct Pipe { + parent: std::fs::File, + child: WinHandle, +} + +fn create_pipe() -> Result<(HANDLE, HANDLE)> { + let mut read: HANDLE = ptr::null_mut(); + let mut write: HANDLE = ptr::null_mut(); + let ok = unsafe { CreatePipe(&mut read, &mut write, ptr::null(), 0) }; + if ok == 0 { + bail!("CreatePipe failed: {}", std::io::Error::last_os_error()); + } + clear_inheritable(read)?; + clear_inheritable(write)?; + Ok((read, write)) +} + +impl Pipe { + fn parent_writes() -> Result { + let (child_read, parent_write) = create_pipe()?; + set_inheritable(child_read)?; + Ok(Self { + parent: unsafe { std::fs::File::from_raw_handle(parent_write) }, + child: WinHandle::new(child_read), + }) + } + + fn parent_reads() -> Result { + let (parent_read, child_write) = create_pipe()?; + set_inheritable(child_write)?; + Ok(Self { + parent: unsafe { std::fs::File::from_raw_handle(parent_read) }, + child: WinHandle::new(child_write), + }) + } +} + +fn nul_stderr_handle() -> Result { + let nul = wide::null_terminated("NUL"); + let handle = unsafe { + CreateFileW( + nul.as_ptr(), + FILE_GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE || handle.is_null() { + bail!( + "CreateFileW(NUL) failed: {}", + std::io::Error::last_os_error() + ); + } + set_inheritable(handle)?; + Ok(handle) +} + +fn set_inheritable(handle: HANDLE) -> Result<()> { + let ok = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }; + if ok == 0 { + bail!( + "SetHandleInformation(HANDLE_FLAG_INHERIT) failed: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) +} + +fn clear_inheritable(handle: HANDLE) -> Result<()> { + let ok = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) }; + if ok == 0 { + bail!( + "SetHandleInformation(clear HANDLE_FLAG_INHERIT) failed: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) +} + +struct SendProcessHandle(HANDLE); + +// SAFETY: Win32 process handles are kernel objects safe to send to a worker thread. +unsafe impl Send for SendProcessHandle {} + +fn terminate_process(process: HANDLE) { + unsafe { + TerminateProcess(process, 1); + } +} + +fn wait_for_exit(process: HANDLE, timeout: Duration, command: &str) -> Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + terminate_process(process); + bail!( + "secret backend {command} timed out after {} seconds", + timeout.as_secs() + ); + } + let ms = remaining.as_millis().min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(process, ms) } { + WAIT_OBJECT_0 => { + let mut code = 0u32; + let ok = unsafe { GetExitCodeProcess(process, &mut code) }; + if ok == 0 { + bail!( + "GetExitCodeProcess failed: {}", + std::io::Error::last_os_error() + ); + } + return Ok(code); + } + WAIT_TIMEOUT => {} + _ => { + bail!( + "WaitForSingleObject failed: {}", + std::io::Error::last_os_error() + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::legacy_scm_env::set_test_core_agent_scm_env; + use std::collections::HashMap; + + fn wide_env_block_to_map(block: &[u16]) -> HashMap { + let mut vars = HashMap::new(); + let mut start = 0usize; + for (idx, &unit) in block.iter().enumerate() { + if unit != 0 { + continue; + } + if idx == start { + break; + } + let entry = String::from_utf16_lossy(&block[start..idx]); + if let Some((key, value)) = entry.split_once('=') { + vars.insert(key.to_string(), value.to_string()); + } + start = idx + 1; + } + vars + } + + #[test] + fn local_system_resolution_env_merges_core_agent_scm_overrides() { + set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_SECRET_BACKEND_COMMAND".to_string(), + r"C:\agent\secret.cmd".to_string(), + )]))); + let env = + secret_backend_resolution_env(&AgentAccount::LocalSystem, None).expect("env"); + assert_eq!( + env.get("DD_SECRET_BACKEND_COMMAND").map(String::as_str), + Some(r"C:\agent\secret.cmd"), + "LocalSystem secret backend must merge datadogagent SCM Environment" + ); + set_test_core_agent_scm_env(None); + } + + #[test] + fn local_system_spawn_env_block_carries_core_agent_scm_overrides() { + set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_CUSTOM_SECRET".to_string(), + "from-scm".to_string(), + )]))); + let env = + secret_backend_resolution_env(&AgentAccount::LocalSystem, None).expect("env"); + let block = env_vars_to_wide_block(&env); + let parsed = wide_env_block_to_map(&block); + assert_eq!( + parsed.get("DD_CUSTOM_SECRET").map(String::as_str), + Some("from-scm"), + "CreateProcessAsUserW env block must include merged SCM overrides" + ); + set_test_core_agent_scm_env(None); + } + + #[test] + fn local_system_supervisor_inherited_env_alone_misses_scm_overrides() { + set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_ONLY_IN_SCM".to_string(), + "scm-value".to_string(), + )]))); + let inherited: HashMap = std::env::vars().collect(); + assert!( + !inherited.contains_key("DD_ONLY_IN_SCM"), + "supervisor inherited env must not include datadogagent SCM-only overrides" + ); + let merged = + secret_backend_resolution_env(&AgentAccount::LocalSystem, None).expect("env"); + assert_eq!( + merged.get("DD_ONLY_IN_SCM").map(String::as_str), + Some("scm-value"), + "spawn must use merged env, not inherited supervisor env alone" + ); + set_test_core_agent_scm_env(None); + } +} diff --git a/pkg/procmgr/rust/src/platform/windows/secret_backend_rights.rs b/pkg/procmgr/rust/src/platform/windows/secret_backend_rights.rs new file mode 100644 index 000000000000..0156232e6256 --- /dev/null +++ b/pkg/procmgr/rust/src/platform/windows/secret_backend_rights.rs @@ -0,0 +1,267 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! DACL validation for `secret_backend_command` executables on Windows. +//! +//! Mirrors `pkg/util/filesystem/rights_windows.go` (`CheckRights`): only LocalSystem, +//! Administrators, and the Agent service account may have rights on the backend binary, +//! and the Agent account must be explicitly allowed to execute it. + +use std::mem::offset_of; +use std::path::Path; +use std::ptr; + +use anyhow::{Context, Result, bail}; +use windows_sys::Win32::Foundation::{LocalFree, WIN32_ERROR}; +use windows_sys::Win32::Security::Authorization::{ + ConvertStringSidToSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, AllocateAndInitializeSid, + DACL_SECURITY_INFORMATION, EqualSid, FreeSid, GetAce, GetAclInformation, PSID, + SECURITY_NT_AUTHORITY, +}; +use windows_sys::Win32::System::SystemServices::{ + DOMAIN_ALIAS_RID_ADMINS, SECURITY_BUILTIN_DOMAIN_RID, +}; + +use super::sid::lookup_account_sid; +use super::wide; +use super::{open_datadog_agent_key, registry_nonempty_string}; + +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; +const ACCESS_DENIED_ACE_TYPE: u8 = 1; +const LOCAL_SYSTEM_SID: &str = "S-1-5-18"; + +/// Validate executable DACLs before running it as the Agent account. +pub(crate) fn check_secret_backend_command_rights(path: &str) -> Result<()> { + if cfg!(test) { + // Unit tests create backends under %TEMP% without installer ACLs; Windows E2E covers this. + return Ok(()); + } + + if !Path::new(path).is_file() { + bail!("secretBackendCommand '{path}' does not exist"); + } + + let local_system = SidLocalAlloc::from_string(LOCAL_SYSTEM_SID)?; + let administrators = SidAllocated::administrators()?; + let secret_user = SidBytes::from_registry_agent_user()?; + + let dacl = file_dacl(path)?; + let mut acl_info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + let ok = unsafe { + GetAclInformation( + dacl.0, + &mut acl_info as *mut _ as *mut _, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + }; + if ok == 0 { + bail!( + "could not query ACLs for '{path}': {}", + std::io::Error::last_os_error() + ); + } + + let secret_user_display = sid_display(secret_user.as_ptr()); + let mut secret_user_allowed = false; + for index in 0..acl_info.AceCount { + let mut ace = ptr::null_mut(); + let ok = unsafe { GetAce(dacl.0, index, &mut ace) }; + if ok == 0 { + bail!( + "could not query an ACE on '{path}': {}", + std::io::Error::last_os_error() + ); + } + + let ace_type = unsafe { (*(ace as *const ACCESS_ALLOWED_ACE)).Header.AceType }; + let ace_sid = ace_sid(ace as *const ACCESS_ALLOWED_ACE); + let is_local_system = sids_equal(ace_sid, local_system.as_ptr()); + let is_administrators = sids_equal(ace_sid, administrators.as_ptr()); + let is_secret_user = sids_equal(ace_sid, secret_user.as_ptr()); + + match ace_type { + ACCESS_DENIED_ACE_TYPE if is_local_system || is_administrators || is_secret_user => { + bail!( + "invalid executable '{path}': explicit deny access for LOCAL_SYSTEM, Administrators or {secret_user_display}" + ); + } + ACCESS_ALLOWED_ACE_TYPE => { + if !(is_local_system || is_administrators || is_secret_user) { + bail!( + "invalid executable '{path}': other users/groups than LOCAL_SYSTEM, Administrators or {secret_user_display} have rights on it" + ); + } + if is_secret_user { + secret_user_allowed = true; + } + } + _ => {} + } + } + + if !secret_user_allowed { + bail!( + "'{secret_user_display}' user is not allowed to execute secretBackendCommand '{path}'" + ); + } + Ok(()) +} + +fn file_dacl(path: &str) -> Result { + let path_w = wide::null_terminated(path); + let mut dacl = ptr::null_mut::(); + let status: WIN32_ERROR = unsafe { + GetNamedSecurityInfoW( + path_w.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if status != 0 { + bail!( + "could not query ACLs for '{path}': {}", + std::io::Error::from_raw_os_error(status as i32) + ); + } + if dacl.is_null() { + bail!("could not query ACLs for '{path}': missing DACL"); + } + Ok(FileDacl(dacl)) +} + +struct FileDacl(*mut ACL); + +fn ace_sid(ace: *const ACCESS_ALLOWED_ACE) -> PSID { + unsafe { (ace as *const u8).add(offset_of!(ACCESS_ALLOWED_ACE, SidStart)) as PSID } +} + +fn sids_equal(left: PSID, right: PSID) -> bool { + unsafe { EqualSid(left, right) != 0 } +} + +fn sid_display(sid: PSID) -> String { + super::sid::sid_to_string(unsafe { + std::slice::from_raw_parts(sid as *const u8, sid_length(sid)) + }) + .unwrap_or_else(|_| "".to_string()) +} + +fn sid_length(sid: PSID) -> usize { + unsafe { + let sub_authority_count = *(sid as *const u8).add(1) as usize; + 8 + sub_authority_count * 4 + } +} + +struct SidBytes(Vec); + +impl SidBytes { + fn from_registry_agent_user() -> Result { + Ok(Self(registry_agent_user_sid()?)) + } + + fn as_ptr(&self) -> PSID { + self.0.as_ptr() as PSID + } +} + +struct SidLocalAlloc(PSID); + +impl SidLocalAlloc { + fn from_string(text: &str) -> Result { + let text_w = wide::null_terminated(text); + let mut sid = ptr::null_mut(); + if unsafe { ConvertStringSidToSidW(text_w.as_ptr(), &mut sid) } == 0 { + bail!( + "ConvertStringSidToSidW({text}): {}", + std::io::Error::last_os_error() + ); + } + Ok(Self(sid)) + } + + fn as_ptr(&self) -> PSID { + self.0 + } +} + +impl Drop for SidLocalAlloc { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0 as _); + } + } + } +} + +struct SidAllocated(PSID); + +impl SidAllocated { + fn administrators() -> Result { + let mut sid = ptr::null_mut(); + let ok = unsafe { + AllocateAndInitializeSid( + &SECURITY_NT_AUTHORITY, + 2, + SECURITY_BUILTIN_DOMAIN_RID, + DOMAIN_ALIAS_RID_ADMINS, + 0, + 0, + 0, + 0, + 0, + 0, + &mut sid, + ) + }; + if ok == 0 { + bail!( + "AllocateAndInitializeSid(Administrators): {}", + std::io::Error::last_os_error() + ); + } + Ok(Self(sid)) + } + + fn as_ptr(&self) -> PSID { + self.0 + } +} + +impl Drop for SidAllocated { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + FreeSid(self.0); + } + } + } +} + +fn registry_agent_user_sid() -> Result> { + let key = open_datadog_agent_key().context("open HKLM\\SOFTWARE\\Datadog\\Datadog Agent")?; + let user = registry_nonempty_string(&key, "installedUser") + .context("read installedUser from registry")?; + let domain = key + .get_string("installedDomain") + .unwrap_or_default() + .trim() + .to_string(); + lookup_account_sid(&domain, &user).with_context(|| format!("lookup SID for {domain}\\{user}")) +} diff --git a/pkg/procmgr/rust/src/secret_backend_exec.rs b/pkg/procmgr/rust/src/secret_backend_exec.rs new file mode 100644 index 000000000000..329b520bbffc --- /dev/null +++ b/pkg/procmgr/rust/src/secret_backend_exec.rs @@ -0,0 +1,264 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Run a `secret_backend_command` synchronously and capture stdout. +//! +//! Platform code chooses the process identity (see `platform::{unix,windows}/secret_backend`). + +use std::io::{Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; + +pub(crate) struct BackendRun<'a> { + pub command: &'a str, + pub arguments: &'a [String], + pub payload: &'a str, + pub timeout: Duration, + pub max_output_bytes: usize, +} + +/// Spawn with the inherited supervisor token (`std::process::Command`). +pub(crate) fn exec_inherited_token(run: &BackendRun<'_>) -> Result { + spawn_and_capture(run, |_| Ok(())) +} + +/// Spawn after optional `Command` setup (e.g. Unix `pre_exec` to drop to the agent user). +pub(crate) fn spawn_and_capture( + run: &BackendRun<'_>, + configure: impl FnOnce(&mut Command) -> Result<()>, +) -> Result { + let mut child = Command::new(run.command); + child + .args(run.arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + configure(&mut child).with_context(|| format!("configure secret backend {}", run.command))?; + let mut child = child + .spawn() + .with_context(|| format!("spawn secret backend {}", run.command))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(run.payload.as_bytes()) + .context("write secret backend payload")?; + } + + let stdout = child.stdout.take().context("read secret backend stdout")?; + let pid = child.id(); + let deadline = Instant::now() + run.timeout; + let command = run.command; + let timeout_secs = run.timeout.as_secs(); + + wait_with_stdout_drain( + stdout, + run.max_output_bytes, + move || kill_process_by_pid(pid), + move || wait_for_command_child(&mut child, deadline, command, timeout_secs), + ) +} + +/// Read stdout on a background thread while waiting for the child to finish. +/// +/// Secret backends may emit more than the OS pipe buffer holds before exiting; draining +/// concurrently avoids a deadlock where the child blocks on a full pipe and we time out. +/// When output exceeds `max_output_bytes`, `kill_child` runs immediately so the backend +/// cannot wedge on a full pipe until the wait loop times out. +pub(crate) fn wait_with_stdout_drain( + stdout: R, + max_output_bytes: usize, + kill_child: K, + wait_for_child: W, +) -> Result +where + R: Read + Send + 'static, + K: Fn() + Send + 'static, + W: FnOnce() -> Result<()>, +{ + thread::scope(|scope| { + let reader = scope.spawn(move || read_stdout_or_kill(stdout, max_output_bytes, kill_child)); + + let wait_result = wait_for_child(); + let output = reader + .join() + .map_err(|_| anyhow::anyhow!("secret backend stdout reader panicked"))??; + wait_result?; + Ok(output) + }) +} + +fn read_stdout_or_kill(stdout: R, max_output_bytes: usize, kill_child: K) -> Result +where + R: Read, + K: Fn(), +{ + match read_limited_stdout(Some(stdout), max_output_bytes) { + Ok(output) => Ok(output), + Err(ReadStdoutError::LimitExceeded(limit)) => { + kill_child(); + bail!("secret backend output exceeded {limit} bytes"); + } + Err(ReadStdoutError::Other(err)) => Err(err), + } +} + +fn wait_for_command_child( + child: &mut Child, + deadline: Instant, + command: &str, + timeout_secs: u64, +) -> Result<()> { + let status = loop { + match child.try_wait().context("poll secret backend")? { + Some(status) => break status, + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + bail!("secret backend {command} timed out after {timeout_secs} seconds"); + } + None => thread::sleep(Duration::from_millis(50)), + } + }; + if !status.success() { + bail!("secret backend {command} exited with {status}"); + } + Ok(()) +} + +pub(crate) enum ReadStdoutError { + LimitExceeded(usize), + Other(anyhow::Error), +} + +pub(crate) fn read_limited_stdout( + stdout: Option, + max_output_bytes: usize, +) -> std::result::Result { + let stdout = stdout + .context("read secret backend stdout") + .map_err(ReadStdoutError::Other)?; + let mut output = Vec::new(); + stdout + .take(max_output_bytes as u64 + 1) + .read_to_end(&mut output) + .context("read secret backend stdout") + .map_err(ReadStdoutError::Other)?; + if output.len() > max_output_bytes { + return Err(ReadStdoutError::LimitExceeded(max_output_bytes)); + } + String::from_utf8(output) + .context("decode secret backend stdout as UTF-8") + .map_err(ReadStdoutError::Other) +} + +#[cfg(unix)] +fn kill_process_by_pid(pid: u32) { + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL); +} + +#[cfg(windows)] +fn kill_process_by_pid(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; + + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if handle.is_null() { + return; + } + TerminateProcess(handle, 1); + CloseHandle(handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wait_with_stdout_drain_reads_concurrently() { + let (reader, mut writer) = std::io::pipe().expect("pipe"); + let payload = vec![b'a'; 131_072]; + let payload_len = payload.len(); + let writer = thread::spawn(move || { + writer.write_all(&payload).expect("write payload"); + }); + + let output = wait_with_stdout_drain( + reader, + 1024 * 1024, + || {}, + || { + writer.join().expect("writer thread"); + Ok(()) + }, + ) + .expect("concurrent drain"); + + assert_eq!(output.len(), payload_len); + } + + #[test] + fn wait_with_stdout_drain_errors_when_output_exceeds_limit() { + let (reader, mut writer) = std::io::pipe().expect("pipe"); + let writer = thread::spawn(move || { + writer.write_all(&vec![b'a'; 1025]).expect("write payload"); + }); + + let err = wait_with_stdout_drain( + reader, + 1024, + || {}, + || { + writer.join().expect("writer thread"); + Ok(()) + }, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("output exceeded 1024 bytes"), + "unexpected error: {err:#}" + ); + } + + #[cfg(unix)] + #[test] + fn spawn_and_capture_drains_stdout_while_child_runs() { + // Pipe buffers are typically 64 KiB; emit more before exit to catch wait-then-read deadlocks. + let run = BackendRun { + command: "sh", + arguments: &["-c".into(), "perl -e 'print \"a\" x 131072'".into()], + payload: "secret-handle", + timeout: Duration::from_secs(5), + max_output_bytes: 1024 * 1024, + }; + let output = spawn_and_capture(&run, |_| Ok(())).expect("stdout drain"); + assert_eq!(output.len(), 131_072); + assert!(output.chars().all(|c| c == 'a')); + } + + #[cfg(unix)] + #[test] + fn spawn_and_capture_kills_child_when_output_exceeds_limit() { + let run = BackendRun { + command: "sh", + arguments: &["-c".into(), "perl -e 'print \"a\" x 131072'".into()], + payload: "secret-handle", + timeout: Duration::from_secs(5), + max_output_bytes: 1024, + }; + let err = spawn_and_capture(&run, |_| Ok(())).unwrap_err(); + assert!( + err.to_string().contains("output exceeded 1024 bytes"), + "unexpected error: {err:#}" + ); + } +} From 76c329b331fa69715b6711aa2145ff29fe192daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20Almaza=20Ramiro?= Date: Fri, 14 Aug 2026 13:00:23 +0200 Subject: [PATCH 3/4] fix(procmgr): clear secret caches on config reload Invalidate secret-backend and Windows SCM env caches before reloading processes.d so config gates re-evaluate with fresh values. --- pkg/procmgr/rust/src/manager/reload.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/procmgr/rust/src/manager/reload.rs b/pkg/procmgr/rust/src/manager/reload.rs index 764a25ce1858..844a892e6f9d 100644 --- a/pkg/procmgr/rust/src/manager/reload.rs +++ b/pkg/procmgr/rust/src/manager/reload.rs @@ -138,6 +138,7 @@ impl ProcessManager { &self, handles: &RuntimeHandles, ) -> Result { + crate::config_gate::clear_secret_caches(); let new_configs = self.config_loader.load(); let removed = self From 01af0ac1ba6998879d29244c2fc719b9264f6b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel=20Almaza=20Ramiro?= Date: Fri, 14 Aug 2026 16:19:07 +0200 Subject: [PATCH 4/4] fix(procmgr): drop duplicate clear_secret_caches stub after rebase --- pkg/procmgr/rust/src/config_gate.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/procmgr/rust/src/config_gate.rs b/pkg/procmgr/rust/src/config_gate.rs index b3760c38f3db..40a457fde2bd 100644 --- a/pkg/procmgr/rust/src/config_gate.rs +++ b/pkg/procmgr/rust/src/config_gate.rs @@ -2714,5 +2714,3 @@ process_config: ); } } - -pub fn clear_secret_caches() {}