diff --git a/crates/dbx-cli/src/commands.rs b/crates/dbx-cli/src/commands.rs index a9ee0d8ca3..3946ccb7e0 100644 --- a/crates/dbx-cli/src/commands.rs +++ b/crates/dbx-cli/src/commands.rs @@ -156,14 +156,16 @@ fn redacted_config(config: &dbx_core::models::connection::ConnectionConfig) -> s } fn redacted_url(config: &dbx_core::models::connection::ConnectionConfig) -> String { - if matches!( + let redacted = if matches!( config.db_type, dbx_core::models::connection::DatabaseType::Sqlite | dbx_core::models::connection::DatabaseType::DuckDb ) { - return redact_embedded_path_url(&config.redacted_connection_url()); - } + redact_embedded_path_url(&config.redacted_connection_url()) + } else { + redact_uri_credentials(&config.redacted_connection_url()) + }; - redact_uri_credentials(&config.redacted_connection_url()) + redact_sensitive_query_params(&redacted) } fn redact_embedded_path_url(value: &str) -> String { @@ -186,6 +188,45 @@ fn redact_uri_credentials(value: &str) -> String { format!("{}{}{}", &value[..authority_start], &authority[at_index + 1..], &rest[authority_len..]) } +fn redact_sensitive_query_params(value: &str) -> String { + let Some(query_start) = value.find('?') else { + return value.to_string(); + }; + let suffix = &value[query_start + 1..]; + let (query, fragment) = match suffix.find('#') { + Some(fragment_start) => (&suffix[..fragment_start], &suffix[fragment_start..]), + None => (suffix, ""), + }; + if query.is_empty() { + return value.to_string(); + } + + let query = query.split('&').map(redact_query_pair).collect::>().join("&"); + format!("{}?{}{}", &value[..query_start], query, fragment) +} + +fn redact_query_pair(pair: &str) -> String { + if pair.is_empty() { + return String::new(); + } + let (key, has_value) = match pair.split_once('=') { + Some((key, _)) => (key, true), + None => (pair, false), + }; + if is_sensitive_query_key(key) { + let separator = if has_value { "=" } else { "" }; + return format!("{key}{separator}"); + } + pair.to_string() +} + +fn is_sensitive_query_key(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + ["password", "passwd", "token", "secret", "key", "cert", "path", "file", "sslkey", "passfile"] + .iter() + .any(|needle| key.contains(needle)) +} + async fn load_headless_connections( ) -> Result, CliEnvelope> { let state = @@ -1073,6 +1114,36 @@ mod tests { std::env::remove_var("DBX_APP_DATA_DIR"); } + #[tokio::test] + async fn conn_list_and_show_redact_sensitive_query_params() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::env::set_var("DBX_APP_DATA_DIR", dir.path()); + let mut mysql = mysql_fixture("prod-id", "prod-main", Some("#ef4444"), "prod-secret"); + mysql.url_params = + Some("password=pw&sslkey=/tmp/client.key&sslcert=/tmp/client.crt&token=abc&charset=utf8mb4".to_string()); + seed_connections(dir.path(), &[mysql]).await; + + let list = success_data(dispatch(vec!["conn".into(), "list".into(), "--format".into(), "json".into()]).await); + let listed_url = list["connections"][0]["redactedUrl"].as_str().unwrap(); + assert!(listed_url.contains("password=")); + assert!(listed_url.contains("sslkey=")); + assert!(listed_url.contains("sslcert=")); + assert!(listed_url.contains("token=")); + assert!(listed_url.contains("charset=utf8mb4")); + + let shown = success_data( + dispatch(vec!["conn".into(), "show".into(), "prod-id".into(), "--format".into(), "json".into()]).await, + ); + let shown_json = serde_json::to_string(&shown).unwrap(); + assert!(!shown_json.contains("pw")); + assert!(!shown_json.contains("/tmp/client.key")); + assert!(!shown_json.contains("/tmp/client.crt")); + assert!(!shown_json.contains("abc")); + + std::env::remove_var("DBX_APP_DATA_DIR"); + } + #[tokio::test] async fn conn_show_unique_id_match_takes_precedence_over_ambiguous_name() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src-tauri/src/commands/agent_runtime.rs b/src-tauri/src/commands/agent_runtime.rs index 74be82af6f..3abf7a7686 100644 --- a/src-tauri/src/commands/agent_runtime.rs +++ b/src-tauri/src/commands/agent_runtime.rs @@ -10,6 +10,7 @@ use tokio::sync::{oneshot, RwLock}; use super::connection::AppState; use dbx_core::handoff::{HandoffItem, HandoffStatus}; +use dbx_core::sql_safety::{risk_for, risk_for_connection, RiskContext}; const BIND_ADDR: &str = "127.0.0.1:0"; const DISCOVERY_FILE: &str = "agent-runtime.json"; @@ -325,10 +326,7 @@ async fn route_request(first_line: &str, body: &str, state: &AgentRuntimeState) if let Some(limit) = query_limit(first_line) { truncate_result_rows(&mut body, limit); } - return RuntimeResponse { - status: "200 OK", - body, - }; + return RuntimeResponse { status: "200 OK", body }; } if first_line.starts_with("POST /handoff ") { @@ -341,6 +339,8 @@ async fn route_request(first_line: &str, body: &str, state: &AgentRuntimeState) }; } }; + let snapshot = state.snapshot.read().await.clone(); + recompute_handoff_risk(&mut item, &snapshot); item.status = dbx_core::handoff::HandoffStatus::Shown; let id = item.id.clone(); state.handoffs.write().await.push(item); @@ -350,7 +350,25 @@ async fn route_request(first_line: &str, body: &str, state: &AgentRuntimeState) RuntimeResponse { status: "404 Not Found", body: serde_json::json!({"error": "not found"}) } } -async fn update_handoff_status(app_state: &AppState, runtime: &AgentRuntimeState, id: &str, status: HandoffStatus) -> Result { +fn recompute_handoff_risk(item: &mut HandoffItem, snapshot: &AgentRuntimeSnapshot) { + let risk = match snapshot.active_connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) { + Some(connection_name) => risk_for_connection(&item.sql, connection_name, None), + None => risk_for( + &item.sql, + RiskContext { connection_name: "unknown", color: None, environment_label: Some("Production") }, + ), + }; + item.operation_class = risk.operation_class; + item.risk_level = risk.risk_level; + item.is_production = risk.is_production; +} + +async fn update_handoff_status( + app_state: &AppState, + runtime: &AgentRuntimeState, + id: &str, + status: HandoffStatus, +) -> Result { let stored = app_state.storage.update_handoff_status(id, status.clone()).await?; let runtime_updated = update_runtime_handoff_status(runtime, id, status).await; Ok(stored || runtime_updated) @@ -555,6 +573,59 @@ mod tests { assert_eq!(state.handoffs.read().await.len(), 1); } + #[tokio::test] + async fn handoff_recomputes_risk_from_sql_instead_of_trusting_client_fields() { + let state = runtime_state(); + *state.snapshot.write().await = AgentRuntimeSnapshot { + active_connection_id: Some("conn-1".to_string()), + active_connection_name: Some("prod-main".to_string()), + ..AgentRuntimeSnapshot::default() + }; + let item = dbx_core::handoff::HandoffItem::queued( + "conn-1".to_string(), + "client-supplied-dev".to_string(), + Some("main".to_string()), + "Review SQL".to_string(), + None, + "drop table users".to_string(), + dbx_core::sql_safety::OperationClass::Read, + dbx_core::sql_safety::RiskLevel::Low, + false, + ); + + let handoff = route_request("POST /handoff HTTP/1.1", &serde_json::to_string(&item).unwrap(), &state).await; + + assert_eq!(handoff.status, "200 OK"); + let stored = state.handoffs.read().await; + assert_eq!(stored[0].operation_class, dbx_core::sql_safety::OperationClass::Ddl); + assert_eq!(stored[0].risk_level, dbx_core::sql_safety::RiskLevel::Critical); + assert!(stored[0].is_production); + } + + #[tokio::test] + async fn handoff_uses_conservative_production_when_connection_metadata_is_unavailable() { + let state = runtime_state(); + let item = dbx_core::handoff::HandoffItem::queued( + "conn-1".to_string(), + "client-supplied-dev".to_string(), + None, + "Review SQL".to_string(), + None, + "update users set name = 'a' where id = 1".to_string(), + dbx_core::sql_safety::OperationClass::Read, + dbx_core::sql_safety::RiskLevel::Low, + false, + ); + + let handoff = route_request("POST /handoff HTTP/1.1", &serde_json::to_string(&item).unwrap(), &state).await; + + assert_eq!(handoff.status, "200 OK"); + let stored = state.handoffs.read().await; + assert_eq!(stored[0].operation_class, dbx_core::sql_safety::OperationClass::Write); + assert_eq!(stored[0].risk_level, dbx_core::sql_safety::RiskLevel::High); + assert!(stored[0].is_production); + } + #[tokio::test] async fn result_current_limit_truncates_rows() { let state = runtime_state();