diff --git a/Cargo.lock b/Cargo.lock index 4e9626f8..a047effe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3316,6 +3316,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4927,7 +4937,9 @@ dependencies = [ "oci-wasm", "policy", "proptest", + "regex", "reqwest", + "secrecy", "serde", "serde_json", "serde_yaml", @@ -4947,6 +4959,7 @@ dependencies = [ "wasmtime-wasi-config", "wasmtime-wasi-http", "windows", + "zeroize", ] [[package]] diff --git a/crates/wassette/Cargo.toml b/crates/wassette/Cargo.toml index 507d99b7..ca6e6e21 100644 --- a/crates/wassette/Cargo.toml +++ b/crates/wassette/Cargo.toml @@ -16,11 +16,14 @@ hyper = { version = "1.7", features = ["client"] } oci-client = { workspace = true } oci-wasm = { workspace = true } policy = { workspace = true } +regex = "1.11" reqwest = { workspace = true } +secrecy = { version = "0.10", features = ["serde"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" serde_yaml = { workspace = true } +zeroize = "1.8" tempfile = { workspace = true } tokio = { workspace = true, features = ["full", "test-util"] } tokio-util = { workspace = true, features = ["io"] } diff --git a/crates/wassette/src/ipc_protocol.rs b/crates/wassette/src/ipc_protocol.rs index accb7d63..f4ec07e2 100644 --- a/crates/wassette/src/ipc_protocol.rs +++ b/crates/wassette/src/ipc_protocol.rs @@ -40,6 +40,7 @@ pub enum IpcCommand { /// Component identifier component_id: String, /// Whether to include secret values in the response + #[serde(default)] show_values: bool, }, } diff --git a/crates/wassette/src/ipc_server.rs b/crates/wassette/src/ipc_server.rs index fcdbb1e6..5c541300 100644 --- a/crates/wassette/src/ipc_server.rs +++ b/crates/wassette/src/ipc_server.rs @@ -159,7 +159,7 @@ async fn handle_request( value, } => { secrets_manager - .set_component_secrets(&component_id, &[(key.clone(), value)]) + .inject_secret(&component_id, key.clone(), value) .await .context("Failed to set secret")?; Ok(IpcResponse::success(format!( @@ -169,14 +169,27 @@ async fn handle_request( } IpcCommand::DeleteSecret { component_id, key } => { - secrets_manager - .delete_component_secrets(&component_id, std::slice::from_ref(&key)) + // Try to delete from memory store first + match secrets_manager + .remove_memory_secret(&component_id, &key) .await - .context("Failed to delete secret")?; - Ok(IpcResponse::success(format!( - "Secret '{}' deleted from component '{}'", - key, component_id - ))) + { + Ok(_) => Ok(IpcResponse::success(format!( + "Secret '{}' deleted from component '{}'", + key, component_id + ))), + Err(_) => { + // If not in memory, try file-based + secrets_manager + .delete_component_secrets(&component_id, std::slice::from_ref(&key)) + .await + .context("Failed to delete secret")?; + Ok(IpcResponse::success(format!( + "Secret '{}' deleted from component '{}'", + key, component_id + ))) + } + } } IpcCommand::ListSecrets { @@ -184,7 +197,7 @@ async fn handle_request( show_values, } => { let secrets = secrets_manager - .list_component_secrets(&component_id, show_values) + .list_all_secrets(&component_id, show_values) .await .context("Failed to list secrets")?; @@ -541,9 +554,9 @@ mod tests { let response = handle_request(request, &secrets_manager).await.unwrap(); assert_eq!(response.status, "success"); - // Verify secret was actually set + // Verify secret was actually set in memory let secrets = secrets_manager - .list_component_secrets("test-component", true) + .list_all_secrets("test-component", true) .await .unwrap(); assert_eq!(secrets.get("API_KEY"), Some(&Some("secret123".to_string()))); diff --git a/crates/wassette/src/lib.rs b/crates/wassette/src/lib.rs index 40fe30b5..b3101ac7 100644 --- a/crates/wassette/src/lib.rs +++ b/crates/wassette/src/lib.rs @@ -319,6 +319,61 @@ pub struct ComponentInstance { package_docs: Option, } +/// Attempt to extract missing environment variable names from error messages +fn extract_missing_env_vars(error_message: &str) -> Vec { + let mut missing_vars = Vec::new(); + + // Common patterns for missing environment variable errors + let patterns = [ + // WASI error: "environment variable not found: API_KEY" + r"environment variable not found:\s*([A-Z_][A-Z0-9_]*)", + // Generic: "missing environment variable API_KEY" + r"missing environment variable\s*([A-Z_][A-Z0-9_]*)", + // env::var error: "environment variable `API_KEY` not found" + r"environment variable `([A-Z_][A-Z0-9_]*)` not found", + // Other variations + r"variable\s+([A-Z_][A-Z0-9_]*)\s+not\s+found", + ]; + + for pattern in &patterns { + if let Ok(re) = regex::Regex::new(pattern) { + for cap in re.captures_iter(error_message) { + if let Some(var_name) = cap.get(1) { + missing_vars.push(var_name.as_str().to_string()); + } + } + } + } + + missing_vars +} + +/// Enhance error message with instructions for missing secrets +fn enhance_missing_secret_error(component_id: &str, error: anyhow::Error) -> anyhow::Error { + let error_msg = error.to_string(); + let missing_vars = extract_missing_env_vars(&error_msg); + + if !missing_vars.is_empty() { + let vars_list = missing_vars.join(", "); + let hint = format!( + "\n\nComponent execution failed. Missing secrets: {}\n\ + To provide the secret(s), use:\n \ + wassette secret set --component {} {}", + vars_list, + component_id, + missing_vars + .iter() + .map(|v| format!("{}=value", v)) + .collect::>() + .join(" ") + ); + + anyhow!("{}{}", error_msg, hint) + } else { + error + } +} + impl LifecycleManager { /// Begin constructing a lifecycle manager with a fluent builder that /// validates configuration and applies sensible defaults. @@ -1076,8 +1131,8 @@ impl LifecycleManager { // Return a more informative error with instructions return Err(anyhow!(perm_error.to_user_message(component_id))); } - // Otherwise, return the original WASM execution error - return Err(e); + // Check if it might be a missing secret error and enhance the message + return Err(enhance_missing_secret_error(component_id, e)); } let result_json = vals_to_json(&results); diff --git a/crates/wassette/src/policy_internal.rs b/crates/wassette/src/policy_internal.rs index e22eec3e..7e3dcda9 100644 --- a/crates/wassette/src/policy_internal.rs +++ b/crates/wassette/src/policy_internal.rs @@ -151,7 +151,8 @@ impl PolicyManager { async fn build_default_template(&self, component_id: &str) -> Arc { let mut config_vars = self.environment_vars.as_ref().clone(); - if let Ok(secrets) = self.secrets.load_component_secrets(component_id).await { + // Load both file-based and in-memory secrets + if let Ok(secrets) = self.secrets.get_all_secrets(component_id).await { for (key, value) in secrets { config_vars.insert(key, value); } @@ -189,7 +190,8 @@ impl PolicyManager { let metadata_path = self.metadata_path(component_id); tokio::fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?).await?; - let secrets = self.secrets.load_component_secrets(component_id).await.ok(); + // Load both file-based and in-memory secrets + let secrets = self.secrets.get_all_secrets(component_id).await.ok(); let wasi_template = crate::create_wasi_state_template_from_policy( &policy, @@ -287,7 +289,8 @@ impl PolicyManager { return Ok(()); } - let secrets = self.secrets.load_component_secrets(component_id).await.ok(); + // Load both file-based and in-memory secrets + let secrets = self.secrets.get_all_secrets(component_id).await.ok(); match tokio::fs::read_to_string(&policy_path).await { Ok(policy_content) => match PolicyParser::parse_str(&policy_content) { diff --git a/crates/wassette/src/secrets.rs b/crates/wassette/src/secrets.rs index 059afcae..d9cb3ec8 100644 --- a/crates/wassette/src/secrets.rs +++ b/crates/wassette/src/secrets.rs @@ -16,8 +16,10 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use anyhow::{anyhow, Context, Result}; +use secrecy::{ExposeSecret, SecretString}; use tokio::sync::RwLock; use tracing::{debug, info, warn}; +use zeroize::Zeroize; /// Cache entry for component secrets #[derive(Debug, Clone)] @@ -33,8 +35,11 @@ pub struct SecretCache { pub struct SecretsManager { /// Directory where secrets are stored secrets_dir: PathBuf, - /// Cache of component secrets + /// Cache of component secrets from files cache: RwLock>, + /// In-memory secrets store for dynamically injected secrets + /// Component ID → (Secret Key → Secret Value) + memory_store: RwLock>>, } impl SecretsManager { @@ -43,6 +48,7 @@ impl SecretsManager { Self { secrets_dir, cache: RwLock::new(HashMap::new()), + memory_store: RwLock::new(HashMap::new()), } } @@ -339,6 +345,103 @@ impl SecretsManager { Ok(()) } + + /// Inject a secret into in-memory store (dynamic secret injection via IPC) + pub async fn inject_secret( + &self, + component_id: &str, + key: String, + value: String, + ) -> Result<()> { + let secret_value = SecretString::new(value.into()); + let mut store = self.memory_store.write().await; + + let component_secrets = store + .entry(component_id.to_string()) + .or_insert_with(HashMap::new); + component_secrets.insert(key.clone(), secret_value); + + info!("Injected secret '{}' for component: {}", key, component_id); + Ok(()) + } + + /// Remove a secret from in-memory store + pub async fn remove_memory_secret(&self, component_id: &str, key: &str) -> Result<()> { + let mut store = self.memory_store.write().await; + + if let Some(component_secrets) = store.get_mut(component_id) { + if let Some(secret) = component_secrets.remove(key) { + // Zeroize the secret before dropping + let mut exposed = secret.expose_secret().to_string(); + exposed.zeroize(); + info!( + "Removed memory secret '{}' for component: {}", + key, component_id + ); + Ok(()) + } else { + Err(anyhow!( + "Memory secret '{}' not found for component: {}", + key, + component_id + )) + } + } else { + Err(anyhow!( + "No memory secrets found for component: {}", + component_id + )) + } + } + + /// Get all secrets for a component (merged from file and memory) + /// Returns a combined map with memory secrets taking precedence over file-based secrets + pub async fn get_all_secrets(&self, component_id: &str) -> Result> { + let mut merged = HashMap::new(); + + // Start with file-based secrets (lower precedence) + let file_secrets = self.load_component_secrets(component_id).await?; + merged.extend(file_secrets); + + // Overlay memory secrets (higher precedence) + let store = self.memory_store.read().await; + if let Some(memory_secrets) = store.get(component_id) { + for (key, secret_value) in memory_secrets { + merged.insert(key.clone(), secret_value.expose_secret().to_string()); + } + } + + Ok(merged) + } + + /// List all secrets for a component (both file-based and memory) + pub async fn list_all_secrets( + &self, + component_id: &str, + show_values: bool, + ) -> Result>> { + let mut result = HashMap::new(); + + // Add file-based secrets + let file_secrets = self + .list_component_secrets(component_id, show_values) + .await?; + result.extend(file_secrets); + + // Add memory secrets + let store = self.memory_store.read().await; + if let Some(memory_secrets) = store.get(component_id) { + for (key, secret_value) in memory_secrets { + if show_values { + result.insert(key.clone(), Some(secret_value.expose_secret().to_string())); + } else { + result.insert(key.clone(), None); + } + } + } + + Ok(result) + } } /// Sanitize component ID for use as filename diff --git a/crates/wassette/src/wasistate.rs b/crates/wassette/src/wasistate.rs index 8c9f804b..71dd9555 100644 --- a/crates/wassette/src/wasistate.rs +++ b/crates/wassette/src/wasistate.rs @@ -273,11 +273,8 @@ pub(crate) fn extract_env_vars( env_vars.extend(secrets_map.clone()); } - // Add inherited environment vars (middle precedence) - // Note: This would require passing process environment, but for now - // we'll just add configured environment_vars which act as inherited - - // Add policy-allowed environment variables (highest precedence) + // Add policy-allowed environment variables from environment_vars (higher precedence) + // These override secrets when keys conflict if let Some(env_perms) = &policy.permissions.environment { if let Some(env_allow_vec) = &env_perms.allow { for env_allow in env_allow_vec { diff --git a/tests/secret_injection_integration_test.rs b/tests/secret_injection_integration_test.rs new file mode 100644 index 00000000..b32713a0 --- /dev/null +++ b/tests/secret_injection_integration_test.rs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! Integration tests for dynamic secret injection + +use std::sync::Arc; + +use anyhow::Result; +use tempfile::TempDir; +use wassette::SecretsManager; + +#[tokio::test] +async fn test_inject_and_retrieve_secret() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Inject a secret into memory + secrets_manager + .inject_secret( + "test-component", + "API_KEY".to_string(), + "secret123".to_string(), + ) + .await?; + + // Retrieve all secrets (should include the injected one) + let all_secrets = secrets_manager.get_all_secrets("test-component").await?; + assert_eq!(all_secrets.get("API_KEY"), Some(&"secret123".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn test_memory_secret_precedence_over_file() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Set a file-based secret + secrets_manager + .set_component_secrets( + "test-component", + &[("API_KEY".to_string(), "file_value".to_string())], + ) + .await?; + + // Inject a memory secret with the same key + secrets_manager + .inject_secret( + "test-component", + "API_KEY".to_string(), + "memory_value".to_string(), + ) + .await?; + + // Retrieve all secrets - memory should override file + let all_secrets = secrets_manager.get_all_secrets("test-component").await?; + assert_eq!( + all_secrets.get("API_KEY"), + Some(&"memory_value".to_string()) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_remove_memory_secret() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Inject a secret + secrets_manager + .inject_secret( + "test-component", + "API_KEY".to_string(), + "secret123".to_string(), + ) + .await?; + + // Verify it's there + let all_secrets = secrets_manager.get_all_secrets("test-component").await?; + assert!(all_secrets.contains_key("API_KEY")); + + // Remove it + secrets_manager + .remove_memory_secret("test-component", "API_KEY") + .await?; + + // Verify it's gone + let all_secrets = secrets_manager.get_all_secrets("test-component").await?; + assert!(!all_secrets.contains_key("API_KEY")); + + Ok(()) +} + +#[tokio::test] +async fn test_list_all_secrets_combines_file_and_memory() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Set a file-based secret + secrets_manager + .set_component_secrets( + "test-component", + &[("FILE_KEY".to_string(), "file_value".to_string())], + ) + .await?; + + // Inject a memory secret + secrets_manager + .inject_secret( + "test-component", + "MEMORY_KEY".to_string(), + "memory_value".to_string(), + ) + .await?; + + // List all secrets + let all_secrets = secrets_manager + .list_all_secrets("test-component", false) + .await?; + + assert_eq!(all_secrets.len(), 2); + assert!(all_secrets.contains_key("FILE_KEY")); + assert!(all_secrets.contains_key("MEMORY_KEY")); + + Ok(()) +} + +#[tokio::test] +async fn test_list_all_secrets_with_values() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Set a file-based secret + secrets_manager + .set_component_secrets( + "test-component", + &[("FILE_KEY".to_string(), "file_value".to_string())], + ) + .await?; + + // Inject a memory secret + secrets_manager + .inject_secret( + "test-component", + "MEMORY_KEY".to_string(), + "memory_value".to_string(), + ) + .await?; + + // List all secrets with values + let all_secrets = secrets_manager + .list_all_secrets("test-component", true) + .await?; + + assert_eq!(all_secrets.len(), 2); + assert_eq!( + all_secrets.get("FILE_KEY"), + Some(&Some("file_value".to_string())) + ); + assert_eq!( + all_secrets.get("MEMORY_KEY"), + Some(&Some("memory_value".to_string())) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_multiple_components_isolated_secrets() -> Result<()> { + let temp_dir = TempDir::new()?; + let secrets_dir = temp_dir.path().join("secrets"); + + let secrets_manager = Arc::new(SecretsManager::new(secrets_dir)); + secrets_manager.ensure_secrets_dir().await?; + + // Inject secrets for component1 + secrets_manager + .inject_secret("component1", "API_KEY".to_string(), "secret1".to_string()) + .await?; + + // Inject secrets for component2 + secrets_manager + .inject_secret("component2", "API_KEY".to_string(), "secret2".to_string()) + .await?; + + // Verify component1 secrets + let comp1_secrets = secrets_manager.get_all_secrets("component1").await?; + assert_eq!(comp1_secrets.get("API_KEY"), Some(&"secret1".to_string())); + + // Verify component2 secrets + let comp2_secrets = secrets_manager.get_all_secrets("component2").await?; + assert_eq!(comp2_secrets.get("API_KEY"), Some(&"secret2".to_string())); + + Ok(()) +}