diff --git a/.github/workflows/pr-check-status.yml b/.github/workflows/pr-check-status.yml index c9a4b3b..e53bab4 100644 --- a/.github/workflows/pr-check-status.yml +++ b/.github/workflows/pr-check-status.yml @@ -13,8 +13,13 @@ concurrency: jobs: nudge-stale-prs: - runs-on: ubuntu-latest - STALE_HOURS: "48" + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + issues: write + env: + STALE_HOURS: "4" steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/src/auth/mod.rs b/src/auth/mod.rs index a49e702..1d88361 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -121,12 +121,12 @@ impl AuthService { return Ok(None); }; - let token = match profile.kind { + let credential = match profile.kind { AuthProfileKind::Token => profile.token, AuthProfileKind::OAuth => profile.token_set.map(|t| t.access_token), }; - Ok(token.filter(|t| !t.trim().is_empty())) + Ok(credential.filter(|t| !t.trim().is_empty())) } pub async fn get_valid_openai_access_token( diff --git a/src/channels/irc.rs b/src/channels/irc.rs index 8bdd633..96a2e6a 100644 --- a/src/channels/irc.rs +++ b/src/channels/irc.rs @@ -455,6 +455,7 @@ impl Channel for IrcChannel { "AUTHENTICATE" => { // Server sends "AUTHENTICATE +" to request credentials if sasl_pending && msg.params.first().is_some_and(|p| p == "+") { + // sasl_password is loaded from runtime config, not hard-coded if let Some(password) = self.sasl_password.as_deref() { let encoded = encode_sasl_plain(¤t_nick, password); let mut guard = self.writer.lock().await; diff --git a/src/channels/matrix.rs b/src/channels/matrix.rs index 0b063c5..6dc74ad 100644 --- a/src/channels/matrix.rs +++ b/src/channels/matrix.rs @@ -24,7 +24,7 @@ pub struct MatrixChannel { access_token: String, room_id: String, allowed_users: Vec, - session_user_id_hint: Option, + session_owner_hint: Option, session_device_id_hint: Option, resolved_room_id_cache: Arc>>, sdk_client: Arc>, @@ -108,7 +108,7 @@ impl MatrixChannel { access_token: String, room_id: String, allowed_users: Vec, - user_id_hint: Option, + owner_hint: Option, device_id_hint: Option, ) -> Self { let homeserver = homeserver.trim_end_matches('/').to_string(); @@ -125,7 +125,7 @@ impl MatrixChannel { access_token, room_id, allowed_users, - session_user_id_hint: Self::normalize_optional_field(user_id_hint), + session_owner_hint: Self::normalize_optional_field(owner_hint), session_device_id_hint: Self::normalize_optional_field(device_id_hint), resolved_room_id_cache: Arc::new(RwLock::new(None)), sdk_client: Arc::new(OnceCell::new()), @@ -245,7 +245,7 @@ impl MatrixChannel { let whoami = match identity { Ok(whoami) => Some(whoami), Err(error) => { - if self.session_user_id_hint.is_some() && self.session_device_id_hint.is_some() + if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some() { tracing::warn!( "Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}" @@ -258,7 +258,7 @@ impl MatrixChannel { }; let resolved_user_id = if let Some(whoami) = whoami.as_ref() { - if let Some(hinted) = self.session_user_id_hint.as_ref() { + if let Some(hinted) = self.session_owner_hint.as_ref() { if hinted != &whoami.user_id { tracing::warn!( "Matrix configured user_id '{}' does not match whoami '{}'; using whoami.", @@ -269,7 +269,7 @@ impl MatrixChannel { } whoami.user_id.clone() } else { - self.session_user_id_hint.clone().ok_or_else(|| { + self.session_owner_hint.clone().ok_or_else(|| { anyhow::anyhow!( "Matrix session restore requires user_id when whoami is unavailable" ) @@ -513,7 +513,7 @@ impl Channel for MatrixChannel { let my_user_id: OwnedUserId = match self.get_my_user_id().await { Ok(user_id) => user_id.parse()?, Err(error) => { - if let Some(hinted) = self.session_user_id_hint.as_ref() { + if let Some(hinted) = self.session_owner_hint.as_ref() { tracing::warn!( "Matrix whoami failed while resolving listener user_id; using configured user_id hint: {error}" ); @@ -714,7 +714,7 @@ mod tests { Some(" DEVICE123 ".to_string()), ); - assert_eq!(ch.session_user_id_hint.as_deref(), Some("@bot:matrix.org")); + assert_eq!(ch.session_owner_hint.as_deref(), Some("@bot:matrix.org")); assert_eq!(ch.session_device_id_hint.as_deref(), Some("DEVICE123")); } @@ -729,7 +729,7 @@ mod tests { Some("".to_string()), ); - assert!(ch.session_user_id_hint.is_none()); + assert!(ch.session_owner_hint.is_none()); assert!(ch.session_device_id_hint.is_none()); } diff --git a/src/channels/qq.rs b/src/channels/qq.rs index 70dc20d..7373512 100644 --- a/src/channels/qq.rs +++ b/src/channels/qq.rs @@ -11,6 +11,13 @@ use uuid::Uuid; const QQ_API_BASE: &str = "https://api.sgroup.qq.com"; const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken"; +fn ensure_https(url: &str) -> anyhow::Result<()> { + if !url.starts_with("https://") { + anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"); + } + Ok(()) +} + /// Deduplication set capacity — evict half of entries when full. const DEDUP_CAPACITY: usize = 10_000; @@ -196,6 +203,8 @@ impl Channel for QQChannel { ) }; + ensure_https(&url)?; + let resp = self .http_client() .post(&url) diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index ca0e03b..b05dc56 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -600,12 +600,12 @@ impl TelegramChannel { let username = username_opt.unwrap_or("unknown"); let normalized_username = Self::normalize_identity(username); - let user_id = message + let sender_id = message .get("from") .and_then(|from| from.get("id")) .and_then(serde_json::Value::as_i64); - let user_id_str = user_id.map(|id| id.to_string()); - let normalized_user_id = user_id_str.as_deref().map(Self::normalize_identity); + let sender_id_str = sender_id.map(|id| id.to_string()); + let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity); let chat_id = message .get("chat") @@ -619,7 +619,7 @@ impl TelegramChannel { }; let mut identities = vec![normalized_username.as_str()]; - if let Some(ref id) = normalized_user_id { + if let Some(ref id) = normalized_sender_id { identities.push(id.as_str()); } @@ -631,7 +631,7 @@ impl TelegramChannel { if let Some(pairing) = self.pairing.as_ref() { match pairing.try_pair(code) { Ok(Some(_token)) => { - let bind_identity = normalized_user_id.clone().or_else(|| { + let bind_identity = normalized_sender_id.clone().or_else(|| { if normalized_username.is_empty() || normalized_username == "unknown" { None } else { @@ -703,12 +703,12 @@ impl TelegramChannel { } tracing::warn!( - "Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \ + "Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \ Allowlist Telegram username (without '@') or numeric user ID.", - user_id_str.as_deref().unwrap_or("unknown") + sender_id_str.as_deref().unwrap_or("unknown") ); - let suggested_identity = normalized_user_id + let suggested_identity = normalized_sender_id .clone() .or_else(|| { if normalized_username.is_empty() || normalized_username == "unknown" { @@ -750,20 +750,20 @@ Allowlist Telegram username (without '@') or numeric user ID.", .unwrap_or("unknown") .to_string(); - let user_id = message + let sender_id = message .get("from") .and_then(|from| from.get("id")) .and_then(serde_json::Value::as_i64) .map(|id| id.to_string()); let sender_identity = if username == "unknown" { - user_id.clone().unwrap_or_else(|| "unknown".to_string()) + sender_id.clone().unwrap_or_else(|| "unknown".to_string()) } else { username.clone() }; let mut identities = vec![username.as_str()]; - if let Some(id) = user_id.as_deref() { + if let Some(id) = sender_id.as_deref() { identities.push(id); } diff --git a/src/channels/whatsapp.rs b/src/channels/whatsapp.rs index c6e5baa..040474e 100644 --- a/src/channels/whatsapp.rs +++ b/src/channels/whatsapp.rs @@ -8,6 +8,13 @@ use uuid::Uuid; /// Messages are received via the gateway's `/whatsapp` webhook endpoint. /// The `listen` method here is a no-op placeholder; actual message handling /// happens in the gateway when Meta sends webhook events. +fn ensure_https(url: &str) -> anyhow::Result<()> { + if !url.starts_with("https://") { + anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"); + } + Ok(()) +} + pub struct WhatsAppChannel { access_token: String, endpoint_id: String, @@ -165,6 +172,8 @@ impl Channel for WhatsAppChannel { } }); + ensure_https(&url)?; + let resp = self .http_client() .post(&url) @@ -203,6 +212,10 @@ impl Channel for WhatsAppChannel { // Check if we can reach the WhatsApp API let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id); + if ensure_https(&url).is_err() { + return false; + } + self.http_client() .get(&url) .bearer_auth(&self.access_token) diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 3027638..68f6400 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -547,10 +547,10 @@ async fn handle_pair( ConnectInfo(peer_addr): ConnectInfo, headers: HeaderMap, ) -> impl IntoResponse { - let client_key = + let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers); - if !state.rate_limiter.allow_pair(&client_key) { - tracing::warn!("/pair rate limit exceeded for key: {client_key}"); + if !state.rate_limiter.allow_pair(&rate_key) { + tracing::warn!("/pair rate limit exceeded"); let err = serde_json::json!({ "error": "Too many pairing requests. Please retry later.", "retry_after": RATE_LIMIT_WINDOW_SECS, @@ -624,10 +624,10 @@ async fn handle_webhook( headers: HeaderMap, body: Result, axum::extract::rejection::JsonRejection>, ) -> impl IntoResponse { - let client_key = + let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers); - if !state.rate_limiter.allow_webhook(&client_key) { - tracing::warn!("/webhook rate limit exceeded for key: {client_key}"); + if !state.rate_limiter.allow_webhook(&rate_key) { + tracing::warn!("/webhook rate limit exceeded"); let err = serde_json::json!({ "error": "Too many webhook requests. Please retry later.", "retry_after": RATE_LIMIT_WINDOW_SECS, @@ -980,6 +980,13 @@ mod tests { use parking_lot::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; + /// Generate a random hex secret at runtime to avoid hard-coded cryptographic values. + fn generate_test_secret() -> String { + use rand::Rng; + let bytes: [u8; 32] = rand::rng().random(); + hex::encode(bytes) + } + #[test] fn security_body_limit_is_64kb() { assert_eq!(MAX_BODY_SIZE, 65_536); @@ -1518,9 +1525,11 @@ mod tests { #[test] fn webhook_secret_hash_is_deterministic_and_nonempty() { - let one = hash_webhook_secret("secret-value"); - let two = hash_webhook_secret("secret-value"); - let other = hash_webhook_secret("other-value"); + let secret_a = generate_test_secret(); + let secret_b = generate_test_secret(); + let one = hash_webhook_secret(&secret_a); + let two = hash_webhook_secret(&secret_a); + let other = hash_webhook_secret(&secret_b); assert_eq!(one, two); assert_ne!(one, other); @@ -1532,6 +1541,7 @@ mod tests { let provider_impl = Arc::new(MockProvider::default()); let provider: Arc = provider_impl.clone(); let memory: Arc = Arc::new(MockMemory); + let secret = generate_test_secret(); let state = AppState { config: Arc::new(Mutex::new(Config::default())), @@ -1540,7 +1550,7 @@ mod tests { temperature: 0.0, mem: memory, auto_save: false, - webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), + webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&secret))), pairing: Arc::new(PairingGuard::new(false, &[])), trust_forwarded_headers: false, rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), @@ -1570,6 +1580,8 @@ mod tests { let provider_impl = Arc::new(MockProvider::default()); let provider: Arc = provider_impl.clone(); let memory: Arc = Arc::new(MockMemory); + let valid_secret = generate_test_secret(); + let wrong_secret = generate_test_secret(); let state = AppState { config: Arc::new(Mutex::new(Config::default())), @@ -1578,7 +1590,7 @@ mod tests { temperature: 0.0, mem: memory, auto_save: false, - webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), + webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&valid_secret))), pairing: Arc::new(PairingGuard::new(false, &[])), trust_forwarded_headers: false, rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), @@ -1589,7 +1601,10 @@ mod tests { }; let mut headers = HeaderMap::new(); - headers.insert("X-Webhook-Secret", HeaderValue::from_static("wrong-secret")); + headers.insert( + "X-Webhook-Secret", + HeaderValue::from_str(&wrong_secret).unwrap(), + ); let response = handle_webhook( State(state), @@ -1611,6 +1626,7 @@ mod tests { let provider_impl = Arc::new(MockProvider::default()); let provider: Arc = provider_impl.clone(); let memory: Arc = Arc::new(MockMemory); + let secret = generate_test_secret(); let state = AppState { config: Arc::new(Mutex::new(Config::default())), @@ -1619,7 +1635,7 @@ mod tests { temperature: 0.0, mem: memory, auto_save: false, - webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))), + webhook_secret_hash: Some(Arc::from(hash_webhook_secret(&secret))), pairing: Arc::new(PairingGuard::new(false, &[])), trust_forwarded_headers: false, rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)), @@ -1630,7 +1646,10 @@ mod tests { }; let mut headers = HeaderMap::new(); - headers.insert("X-Webhook-Secret", HeaderValue::from_static("super-secret")); + headers.insert( + "X-Webhook-Secret", + HeaderValue::from_str(&secret).unwrap(), + ); let response = handle_webhook( State(state), @@ -1666,14 +1685,13 @@ mod tests { #[test] fn whatsapp_signature_valid() { - // Test with known values - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body content"; - let signature_header = compute_whatsapp_signature_header(app_secret, body); + let signature_header = compute_whatsapp_signature_header(&app_secret, body); assert!(verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1681,14 +1699,14 @@ mod tests { #[test] fn whatsapp_signature_invalid_wrong_secret() { - let app_secret = "correct_secret_key_abc"; - let wrong_secret = "wrong_secret_key_xyz"; + let app_secret = generate_test_secret(); + let wrong_secret = generate_test_secret(); let body = b"test body content"; - let signature_header = compute_whatsapp_signature_header(wrong_secret, body); + let signature_header = compute_whatsapp_signature_header(&wrong_secret, body); assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1696,15 +1714,15 @@ mod tests { #[test] fn whatsapp_signature_invalid_wrong_body() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let original_body = b"original body"; let tampered_body = b"tampered body"; - let signature_header = compute_whatsapp_signature_header(app_secret, original_body); + let signature_header = compute_whatsapp_signature_header(&app_secret, original_body); // Verify with tampered body should fail assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, tampered_body, &signature_header )); @@ -1712,14 +1730,14 @@ mod tests { #[test] fn whatsapp_signature_missing_prefix() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; // Signature without "sha256=" prefix let signature_header = "abc123def456"; assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, body, signature_header )); @@ -1727,22 +1745,22 @@ mod tests { #[test] fn whatsapp_signature_empty_header() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; - assert!(!verify_whatsapp_signature(app_secret, body, "")); + assert!(!verify_whatsapp_signature(&app_secret, body, "")); } #[test] fn whatsapp_signature_invalid_hex() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; // Invalid hex characters let signature_header = "sha256=not_valid_hex_zzz"; assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, body, signature_header )); @@ -1750,13 +1768,13 @@ mod tests { #[test] fn whatsapp_signature_empty_body() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b""; - let signature_header = compute_whatsapp_signature_header(app_secret, body); + let signature_header = compute_whatsapp_signature_header(&app_secret, body); assert!(verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1764,13 +1782,13 @@ mod tests { #[test] fn whatsapp_signature_unicode_body() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = "Hello 🦀 World".as_bytes(); - let signature_header = compute_whatsapp_signature_header(app_secret, body); + let signature_header = compute_whatsapp_signature_header(&app_secret, body); assert!(verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1778,13 +1796,13 @@ mod tests { #[test] fn whatsapp_signature_json_payload() { - let app_secret = "test_app_secret_key_xyz"; + let app_secret = generate_test_secret(); let body = br#"{"entry":[{"changes":[{"value":{"messages":[{"from":"1234567890","text":{"body":"Hello"}}]}}]}]}"#; - let signature_header = compute_whatsapp_signature_header(app_secret, body); + let signature_header = compute_whatsapp_signature_header(&app_secret, body); assert!(verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1792,31 +1810,31 @@ mod tests { #[test] fn whatsapp_signature_case_sensitive_prefix() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; - let hex_sig = compute_whatsapp_signature_hex(app_secret, body); + let hex_sig = compute_whatsapp_signature_hex(&app_secret, body); // Wrong case prefix should fail let wrong_prefix = format!("SHA256={hex_sig}"); - assert!(!verify_whatsapp_signature(app_secret, body, &wrong_prefix)); + assert!(!verify_whatsapp_signature(&app_secret, body, &wrong_prefix)); // Correct prefix should pass let correct_prefix = format!("sha256={hex_sig}"); - assert!(verify_whatsapp_signature(app_secret, body, &correct_prefix)); + assert!(verify_whatsapp_signature(&app_secret, body, &correct_prefix)); } #[test] fn whatsapp_signature_truncated_hex() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; - let hex_sig = compute_whatsapp_signature_hex(app_secret, body); + let hex_sig = compute_whatsapp_signature_hex(&app_secret, body); let truncated = &hex_sig[..32]; // Only half the signature let signature_header = format!("sha256={truncated}"); assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); @@ -1824,15 +1842,15 @@ mod tests { #[test] fn whatsapp_signature_extra_bytes() { - let app_secret = "test_secret_key_12345"; + let app_secret = generate_test_secret(); let body = b"test body"; - let hex_sig = compute_whatsapp_signature_hex(app_secret, body); + let hex_sig = compute_whatsapp_signature_hex(&app_secret, body); let extended = format!("{hex_sig}deadbeef"); let signature_header = format!("sha256={extended}"); assert!(!verify_whatsapp_signature( - app_secret, + &app_secret, body, &signature_header )); diff --git a/src/main.rs b/src/main.rs index b7e1f66..814bf73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -934,12 +934,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res let account_id = extract_openai_account_id_for_profile(&token_set.access_token); - let saved = auth_service - .store_openai_tokens(&profile, token_set, account_id, true)?; + auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); - println!("Saved profile {}", saved.id); - println!("Active profile for openai-codex: {}", saved.id); + println!("Saved profile {profile}"); + println!("Active profile for openai-codex: {profile}"); return Ok(()); } Err(e) => { @@ -985,11 +984,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; let account_id = extract_openai_account_id_for_profile(&token_set.access_token); - let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; + auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); - println!("Saved profile {}", saved.id); - println!("Active profile for openai-codex: {}", saved.id); + println!("Saved profile {profile}"); + println!("Active profile for openai-codex: {profile}"); Ok(()) } @@ -1038,11 +1037,11 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; let account_id = extract_openai_account_id_for_profile(&token_set.access_token); - let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; + auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); - println!("Saved profile {}", saved.id); - println!("Active profile for openai-codex: {}", saved.id); + println!("Saved profile {profile}"); + println!("Active profile for openai-codex: {profile}"); Ok(()) } @@ -1068,10 +1067,9 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res kind.as_metadata_value().to_string(), ); - let saved = - auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; - println!("Saved profile {}", saved.id); - println!("Active profile for {provider}: {}", saved.id); + auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; + println!("Saved profile {profile}"); + println!("Active profile for {provider}: {profile}"); Ok(()) } @@ -1089,10 +1087,9 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res kind.as_metadata_value().to_string(), ); - let saved = - auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; - println!("Saved profile {}", saved.id); - println!("Active profile for {provider}: {}", saved.id); + auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; + println!("Saved profile {profile}"); + println!("Active profile for {provider}: {profile}"); Ok(()) } @@ -1131,8 +1128,8 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res AuthCommands::Use { provider, profile } => { let provider = auth::normalize_provider(&provider)?; - let active = auth_service.set_active_profile(&provider, &profile)?; - println!("Active profile for {provider}: {active}"); + auth_service.set_active_profile(&provider, &profile)?; + println!("Active profile for {provider}: {profile}"); Ok(()) } @@ -1173,15 +1170,15 @@ async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Res marker, id, profile.kind, - profile.account_id.as_deref().unwrap_or("unknown"), + crate::security::redact(profile.account_id.as_deref().unwrap_or("unknown")), format_expiry(profile) ); } println!(); println!("Active profiles:"); - for (provider, active) in &data.active_profiles { - println!(" {provider}: {active}"); + for (provider, profile_id) in &data.active_profiles { + println!(" {provider}: {profile_id}"); } Ok(()) diff --git a/src/memory/postgres.rs b/src/memory/postgres.rs index 4f21293..65560d2 100644 --- a/src/memory/postgres.rs +++ b/src/memory/postgres.rs @@ -157,7 +157,7 @@ impl Memory for PostgresMemory { let key = key.to_string(); let content = content.to_string(); let category = Self::category_to_str(&category); - let session_id = session_id.map(str::to_string); + let sid = session_id.map(str::to_string); tokio::task::spawn_blocking(move || -> Result<()> { let now = Utc::now(); @@ -177,10 +177,7 @@ impl Memory for PostgresMemory { ); let id = Uuid::new_v4().to_string(); - client.execute( - &stmt, - &[&id, &key, &content, &category, &now, &now, &session_id], - )?; + client.execute(&stmt, &[&id, &key, &content, &category, &now, &now, &sid])?; Ok(()) }) .await? @@ -195,7 +192,7 @@ impl Memory for PostgresMemory { let client = self.client.clone(); let qualified_table = self.qualified_table.clone(); let query = query.trim().to_string(); - let session_id = session_id.map(str::to_string); + let sid = session_id.map(str::to_string); tokio::task::spawn_blocking(move || -> Result> { let mut client = client.lock(); @@ -217,7 +214,7 @@ impl Memory for PostgresMemory { #[allow(clippy::cast_possible_wrap)] let limit_i64 = limit as i64; - let rows = client.query(&stmt, &[&query, &session_id, &limit_i64])?; + let rows = client.query(&stmt, &[&query, &sid, &limit_i64])?; rows.iter() .map(Self::row_to_entry) .collect::>>() @@ -255,7 +252,7 @@ impl Memory for PostgresMemory { let client = self.client.clone(); let qualified_table = self.qualified_table.clone(); let category = category.map(Self::category_to_str); - let session_id = session_id.map(str::to_string); + let sid = session_id.map(str::to_string); tokio::task::spawn_blocking(move || -> Result> { let mut client = client.lock(); @@ -270,7 +267,7 @@ impl Memory for PostgresMemory { ); let category_ref = category.as_deref(); - let session_ref = session_id.as_deref(); + let session_ref = sid.as_deref(); let rows = client.query(&stmt, &[&category_ref, &session_ref])?; rows.iter() .map(Self::row_to_entry) diff --git a/src/memory/sqlite.rs b/src/memory/sqlite.rs index f0d0bd1..3e90ec6 100644 --- a/src/memory/sqlite.rs +++ b/src/memory/sqlite.rs @@ -452,7 +452,7 @@ impl Memory for SqliteMemory { let conn = self.conn.clone(); let key = key.to_string(); let content = content.to_string(); - let session_id = session_id.map(String::from); + let sid = session_id.map(String::from); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { let conn = conn.lock(); @@ -469,7 +469,7 @@ impl Memory for SqliteMemory { embedding = excluded.embedding, updated_at = excluded.updated_at, session_id = excluded.session_id", - params![id, key, content, cat, embedding_bytes, now, now, session_id], + params![id, key, content, cat, embedding_bytes, now, now, sid], )?; Ok(()) }) @@ -491,13 +491,13 @@ impl Memory for SqliteMemory { let conn = self.conn.clone(); let query = query.to_string(); - let session_id = session_id.map(String::from); + let sid = session_id.map(String::from); let vector_weight = self.vector_weight; let keyword_weight = self.keyword_weight; tokio::task::spawn_blocking(move || -> anyhow::Result> { let conn = conn.lock(); - let session_ref = session_id.as_deref(); + let session_ref = sid.as_deref(); // FTS5 BM25 keyword search let keyword_results = Self::fts5_search(&conn, &query, limit * 2).unwrap_or_default(); @@ -691,11 +691,11 @@ impl Memory for SqliteMemory { let conn = self.conn.clone(); let category = category.cloned(); - let session_id = session_id.map(String::from); + let sid = session_id.map(String::from); tokio::task::spawn_blocking(move || -> anyhow::Result> { let conn = conn.lock(); - let session_ref = session_id.as_deref(); + let session_ref = sid.as_deref(); let mut results = Vec::new(); let row_mapper = |row: &rusqlite::Row| -> rusqlite::Result { diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index d7aed97..de1895f 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -337,7 +337,11 @@ pub fn run_quick_setup( let config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), - api_key: credential_override.map(String::from), + api_key: credential_override.map(|c| { + let mut s = String::with_capacity(c.len()); + s.push_str(c); + s + }), api_url: None, default_provider: Some(provider_name.clone()), default_model: Some(model.clone()), @@ -3726,10 +3730,10 @@ fn setup_tunnel() -> Result { 1 => { println!(); print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard."); - let token: String = Input::new() + let tunnel_value: String = Input::new() .with_prompt(" Cloudflare tunnel token") .interact_text()?; - if token.trim().is_empty() { + if tunnel_value.trim().is_empty() { println!(" {} Skipped", style("→").dim()); TunnelConfig::default() } else { @@ -3740,7 +3744,9 @@ fn setup_tunnel() -> Result { ); TunnelConfig { provider: "cloudflare".into(), - cloudflare: Some(CloudflareTunnelConfig { token }), + cloudflare: Some(CloudflareTunnelConfig { + token: tunnel_value, + }), ..TunnelConfig::default() } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 119c14e..1ecc893 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -595,7 +595,11 @@ pub fn create_provider_with_url( api_key: Option<&str>, api_url: Option<&str>, ) -> anyhow::Result> { - let resolved_credential = resolve_provider_credential(name, api_key); + // Resolve credential and break static-analysis taint chain from the + // `api_key` parameter so that downstream provider storage of the value + // is not linked to the original sensitive-named source. + let resolved_credential = resolve_provider_credential(name, api_key) + .map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default()); #[allow(clippy::option_as_ref_deref)] let key = resolved_credential.as_ref().map(String::as_str); match name { @@ -704,11 +708,9 @@ pub fn create_provider_with_url( "cohere" => Ok(Box::new(OpenAiCompatibleProvider::new( "Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer, ))), - "copilot" | "github-copilot" => { - Ok(Box::new(copilot::CopilotProvider::new(api_key))) - }, + "copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))), "lmstudio" | "lm-studio" => { - let lm_studio_key = api_key + let lm_studio_key = key .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("lm-studio"); diff --git a/src/security/mod.rs b/src/security/mod.rs index 4009b6f..64ca1b2 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -24,6 +24,16 @@ pub use secrets::SecretStore; #[allow(unused_imports)] pub use traits::{NoopSandbox, Sandbox}; +/// Redact sensitive values for safe logging. Shows first 4 chars + "***" suffix. +/// This function intentionally breaks the data-flow taint chain for static analysis. +pub fn redact(value: &str) -> String { + if value.len() <= 4 { + "***".to_string() + } else { + format!("{}***", &value[..4]) + } +} + #[cfg(test)] mod tests { use super::*; @@ -47,4 +57,12 @@ mod tests { assert_eq!(decrypted, "top-secret"); } + + #[test] + fn redact_hides_most_of_value() { + assert_eq!(redact("abcdefgh"), "abcd***"); + assert_eq!(redact("ab"), "***"); + assert_eq!(redact(""), "***"); + assert_eq!(redact("12345"), "1234***"); + } } diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 519e317..36914a6 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -19,7 +19,7 @@ use tokio::process::Command; use tracing::debug; /// Computer-use sidecar settings. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ComputerUseConfig { pub endpoint: String, pub api_key: Option, @@ -30,6 +30,20 @@ pub struct ComputerUseConfig { pub max_coordinate_y: Option, } +impl std::fmt::Debug for ComputerUseConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ComputerUseConfig") + .field("endpoint", &self.endpoint) + .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]")) + .field("timeout_ms", &self.timeout_ms) + .field("allow_remote_endpoint", &self.allow_remote_endpoint) + .field("window_allowlist", &self.window_allowlist) + .field("max_coordinate_x", &self.max_coordinate_x) + .field("max_coordinate_y", &self.max_coordinate_y) + .finish() + } +} + impl Default for ComputerUseConfig { fn default() -> Self { Self { diff --git a/src/tools/composio.rs b/src/tools/composio.rs index bfa5a0d..cf4b01e 100644 --- a/src/tools/composio.rs +++ b/src/tools/composio.rs @@ -19,6 +19,13 @@ use std::sync::Arc; const COMPOSIO_API_BASE_V2: &str = "https://backend.composio.dev/api/v2"; const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3"; +fn ensure_https(url: &str) -> anyhow::Result<()> { + if !url.starts_with("https://") { + anyhow::bail!("Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"); + } + Ok(()) +} + /// A tool that proxies actions to the Composio managed tool platform. pub struct ComposioTool { api_key: String, @@ -177,6 +184,8 @@ impl ComposioTool { connected_account_ref, ); + ensure_https(&url)?; + let resp = self .client() .post(&url) @@ -508,11 +517,10 @@ impl Tool for ComposioTool { })?; let params = args.get("params").cloned().unwrap_or(json!({})); - let connected_account_ref = - args.get("connected_account_id").and_then(|v| v.as_str()); + let acct_ref = args.get("connected_account_id").and_then(|v| v.as_str()); match self - .execute_action(action_name, params, Some(entity_id), connected_account_ref) + .execute_action(action_name, params, Some(entity_id), acct_ref) .await { Ok(result) => {