diff --git a/crates/bashkit/src/ssh/config.rs b/crates/bashkit/src/ssh/config.rs index 17193d02..396a97c9 100644 --- a/crates/bashkit/src/ssh/config.rs +++ b/crates/bashkit/src/ssh/config.rs @@ -47,7 +47,7 @@ pub const DEFAULT_PORT: u16 = 22; /// - Host allowlist is default-deny (empty blocks everything) /// - Keys are read from VFS only, never from host filesystem /// - All connections have timeouts to prevent hangs -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SshConfig { /// Host allowlist pub(crate) allowlist: SshAllowlist, @@ -67,6 +67,29 @@ pub struct SshConfig { pub(crate) default_port: u16, } +// THREAT[TM-INF-016]: Redact credentials in Debug output to prevent +// passwords and private keys from leaking into logs, error messages, or LLM context. +impl std::fmt::Debug for SshConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SshConfig") + .field("allowlist", &self.allowlist) + .field("default_user", &self.default_user) + .field( + "default_password", + &self.default_password.as_ref().map(|_| "[REDACTED]"), + ) + .field( + "default_private_key", + &self.default_private_key.as_ref().map(|_| "[REDACTED]"), + ) + .field("timeout", &self.timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("max_sessions", &self.max_sessions) + .field("default_port", &self.default_port) + .finish() + } +} + impl Default for SshConfig { fn default() -> Self { Self { @@ -213,6 +236,23 @@ mod tests { assert_eq!(config.default_port, 2222); } + #[test] + fn test_debug_redacts_credentials() { + let config = SshConfig::new() + .default_password("super_secret_password") + .default_private_key("-----BEGIN OPENSSH PRIVATE KEY-----"); + let debug = format!("{:?}", config); + assert!( + !debug.contains("super_secret_password"), + "password leaked in Debug: {debug}" + ); + assert!( + !debug.contains("BEGIN OPENSSH PRIVATE KEY"), + "private key leaked in Debug: {debug}" + ); + assert!(debug.contains("[REDACTED]"), "REDACTED missing: {debug}"); + } + #[test] fn test_allowlist_integration() { let config = SshConfig::new().allow("*.supabase.co").allow_port(22); diff --git a/crates/bashkit/src/ssh/handler.rs b/crates/bashkit/src/ssh/handler.rs index ee9cab49..fab19372 100644 --- a/crates/bashkit/src/ssh/handler.rs +++ b/crates/bashkit/src/ssh/handler.rs @@ -14,7 +14,7 @@ use async_trait::async_trait; /// /// Fully resolved by the builtin before passing to the handler. /// The handler does NOT need to validate the host — that's already done. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SshTarget { /// Remote hostname or IP. pub host: String, @@ -28,6 +28,22 @@ pub struct SshTarget { pub password: Option, } +// THREAT[TM-INF-016]: Redact credentials in Debug output. +impl std::fmt::Debug for SshTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SshTarget") + .field("host", &self.host) + .field("port", &self.port) + .field("user", &self.user) + .field( + "private_key", + &self.private_key.as_ref().map(|_| "[REDACTED]"), + ) + .field("password", &self.password.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + /// Output from a remote command execution. #[derive(Debug, Clone, Default)] pub struct SshOutput { @@ -126,3 +142,30 @@ pub trait SshHandler: Send + Sync { remote_path: &str, ) -> std::result::Result, String>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_debug_redacts_credentials() { + let target = SshTarget { + host: "example.com".to_string(), + port: 22, + user: "admin".to_string(), + private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----".to_string()), + password: Some("super_secret".to_string()), + }; + let debug = format!("{:?}", target); + assert!(!debug.contains("super_secret"), "password leaked: {debug}"); + assert!( + !debug.contains("BEGIN OPENSSH PRIVATE KEY"), + "key leaked: {debug}" + ); + assert!(debug.contains("[REDACTED]"), "REDACTED missing: {debug}"); + assert!( + debug.contains("example.com"), + "host should be visible: {debug}" + ); + } +}