Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/wassette/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions crates/wassette/src/ipc_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
Expand Down
35 changes: 24 additions & 11 deletions crates/wassette/src/ipc_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -169,22 +169,35 @@ 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
)))
}
}
Comment on lines +172 to +192
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Silent error swallowing: When remove_memory_secret fails (line 174), the error is silently ignored (the Err(_) pattern on line 181 doesn't log or report why it failed). This makes debugging difficult when a user tries to delete a secret and it's unclear whether it wasn't found in memory or there was another issue.

Consider logging the error before falling back:

match secrets_manager
    .remove_memory_secret(&component_id, &key)
    .await
{
    Ok(_) => Ok(IpcResponse::success(format!(
        "Secret '{}' deleted from component '{}'",
        key, component_id
    ))),
    Err(memory_err) => {
        // Log that memory delete failed, trying file-based
        debug!(
            "Memory secret not found, trying file-based: {}",
            memory_err
        );
        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
        )))
    }
}

Copilot uses AI. Check for mistakes.
}

IpcCommand::ListSecrets {
component_id,
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")?;

Expand Down Expand Up @@ -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())));
Expand Down
59 changes: 57 additions & 2 deletions crates/wassette/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,61 @@ pub struct ComponentInstance {
package_docs: Option<Value>,
}

/// Attempt to extract missing environment variable names from error messages
fn extract_missing_env_vars(error_message: &str) -> Vec<String> {
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",
Comment on lines +328 to +335
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex patterns are overly restrictive. They only match environment variables that start with uppercase letters and contain only uppercase letters, numbers, and underscores (e.g., API_KEY). This will miss environment variables with lowercase letters or mixed case (e.g., Path, HOME, myVar), which are valid in many contexts.

Consider using a more flexible pattern like:

r"environment variable not found:\s*([A-Za-z_][A-Za-z0-9_]*)"

Or if you specifically want to detect only uppercase variables (as suggested by the naming convention), add a comment explaining this decision.

Suggested change
// 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",
// WASI error: "environment variable not found: API_KEY" (now matches any valid env var name)
r"environment variable not found:\s*([A-Za-z_][A-Za-z0-9_]*)",
// Generic: "missing environment variable API_KEY"
r"missing environment variable\s*([A-Za-z_][A-Za-z0-9_]*)",
// env::var error: "environment variable `API_KEY` not found"
r"environment variable `([A-Za-z_][A-Za-z0-9_]*)` not found",
// Other variations
r"variable\s+([A-Za-z_][A-Za-z0-9_]*)\s+not\s+found",

Copilot uses AI. Check for mistakes.
];

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());
}
}
}
Comment on lines +338 to +345
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex compilation is inside the loop, which means each pattern is compiled multiple times for each error message. This is inefficient and could impact performance if many errors are processed.

Consider using lazy_static or once_cell to compile the regexes once and reuse them:

use once_cell::sync::Lazy;

static ENV_VAR_PATTERNS: Lazy<Vec<regex::Regex>> = Lazy::new(|| {
    vec![
        regex::Regex::new(r"environment variable not found:\s*([A-Z_][A-Z0-9_]*)").unwrap(),
        regex::Regex::new(r"missing environment variable\s*([A-Z_][A-Z0-9_]*)").unwrap(),
        // ...
    ]
});

Alternatively, the error handling if let Ok(re) silently ignores regex compilation errors, which could hide bugs. Since these are static patterns, they should be validated at compile time or initialization.

Copilot uses AI. Check for mistakes.
}

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::<Vec<_>>()
.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.
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions crates/wassette/src/policy_internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ impl PolicyManager {
async fn build_default_template(&self, component_id: &str) -> Arc<WasiStateTemplate> {
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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
105 changes: 104 additions & 1 deletion crates/wassette/src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<HashMap<String, SecretCache>>,
/// In-memory secrets store for dynamically injected secrets
/// Component ID → (Secret Key → Secret Value)
memory_store: RwLock<HashMap<String, HashMap<String, SecretString>>>,
}

impl SecretsManager {
Expand All @@ -43,6 +48,7 @@ impl SecretsManager {
Self {
secrets_dir,
cache: RwLock::new(HashMap::new()),
memory_store: RwLock::new(HashMap::new()),
}
}

Expand Down Expand Up @@ -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(())
Comment on lines +373 to +381
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: The zeroization logic has a flaw. Creating a new owned String from expose_secret() means the original secret data in SecretString is not zeroized - only the copy is. The SecretString itself should handle zeroization when dropped.

This manual zeroization is unnecessary and misleading. The correct approach is to simply drop the SecretString, which will automatically handle zeroization internally (as it implements Drop with zeroization):

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 component_secrets.remove(key).is_some() {
            // SecretString handles zeroization automatically when dropped
            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
        ))
    }
}

Copilot uses AI. Check for mistakes.
} 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<HashMap<String, String>> {
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<HashMap<String, Option<String>>> {
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);
}
Comment on lines +425 to +439
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Potential issue with precedence in listing: When a secret key exists in both file-based and memory stores, the list_all_secrets function will add the file-based secret first (line 429), then overwrite it with the memory secret (line 436). While this produces the correct result (memory secrets take precedence), it's less efficient and less clear than necessary.

Consider checking for duplicates to avoid unnecessary work:

// Add file-based secrets
let file_secrets = self
    .list_component_secrets(component_id, show_values)
    .await?;
result.extend(file_secrets);

// Add/override with 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 {
        let value = if show_values {
            Some(secret_value.expose_secret().to_string())
        } else {
            None
        };
        result.insert(key.clone(), value); // Explicitly shows override behavior
    }
}

This makes the precedence behavior more explicit and matches the pattern used in get_all_secrets.

Suggested change
// 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);
}
// Add file-based secrets (lower precedence)
let file_secrets = self
.list_component_secrets(component_id, show_values)
.await?;
result.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 {
let value = if show_values {
Some(secret_value.expose_secret().to_string())
} else {
None
};
result.insert(key.clone(), value); // Explicitly shows override behavior

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +397 to +441
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Potential data race in secret exposure: Multiple concurrent calls to get_all_secrets or list_all_secrets will each call expose_secret() on the same SecretString instances (lines 410, 436). While secrecy crate's expose_secret() is safe to call concurrently (it only provides a reference), creating multiple String copies of secrets in memory increases the attack surface.

Consider one of the following approaches:

  1. Document that this is acceptable for the use case (secrets need to be exposed to pass to WASM anyway)
  2. Add rate limiting or caching to reduce the number of simultaneous exposures
  3. Add a comment explaining the security trade-off

Since the primary use case is providing secrets to components, and they will be exposed anyway, option 1 (documentation) is likely sufficient.

Copilot uses AI. Check for mistakes.

Ok(result)
}
Comment on lines +349 to +444
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing documentation: The new functions inject_secret, remove_memory_secret, get_all_secrets, and list_all_secrets lack comprehensive documentation. While they have basic doc comments, they should document:

  1. Security characteristics: That secrets are stored in memory and lost on restart
  2. Precedence behavior: That memory secrets override file-based secrets
  3. Concurrency guarantees: That operations are atomic with respect to each other
  4. Error conditions: When each error type is returned

Example for inject_secret:

/// Inject a secret into the in-memory store (dynamic secret injection via IPC).
///
/// Memory secrets are:
/// - Stored only in RAM and lost on server restart
/// - Take precedence over file-based secrets with the same key
/// - Immediately available to components without reload
/// - Zeroized when removed or on drop
///
/// # Arguments
/// * `component_id` - The component to associate the secret with
/// * `key` - The secret's environment variable name
/// * `value` - The secret value (will be wrapped in SecretString)
///
/// # Thread Safety
/// This operation is atomic - concurrent calls will not corrupt the store.

Copilot uses AI. Check for mistakes.
}

/// Sanitize component ID for use as filename
Expand Down
7 changes: 2 additions & 5 deletions crates/wassette/src/wasistate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading