diff --git a/crates/goose/src/agents/extension_malware_check.rs b/crates/goose/src/agents/extension_malware_check.rs index 07512f16b..1acad54ab 100644 --- a/crates/goose/src/agents/extension_malware_check.rs +++ b/crates/goose/src/agents/extension_malware_check.rs @@ -41,16 +41,8 @@ impl OsvChecker { } } -/// Convenience: infer ecosystem from command token + parse first package arg. -/// - ends_with("npx") → npm -/// - ends_with("uvx") → PyPI -/// unknown commands → skip (fail open) pub async fn deny_if_malicious_cmd_args(cmd: &str, args: &[String]) -> Result<(), ExtensionError> { - let ecosystem = if cmd.ends_with("uvx") { - "PyPI" - } else if cmd.ends_with("npx") { - "npm" - } else { + let Some(ecosystem) = command_ecosystem(cmd) else { debug!(%cmd, ?args, "Unknown ecosystem for command; skipping OSV check (fail open)."); return Ok(()); }; @@ -67,6 +59,19 @@ pub async fn deny_if_malicious_cmd_args(cmd: &str, args: &[String]) -> Result<() Ok(()) } +fn command_ecosystem(cmd: &str) -> Option<&'static str> { + let executable = cmd + .rsplit(['/', '\\']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + match executable.as_str() { + "npx" | "npx.cmd" => Some("npm"), + "uvx" | "uvx.exe" => Some("PyPI"), + _ => None, + } +} + /// Direct call without command inference. pub async fn deny_if_malicious( name: &str, @@ -708,6 +713,94 @@ mod tests { assert!(msg.contains("MAL-9999")); } + #[tokio::test] + #[serial_test::serial] + async fn cmd_args_windows_shims_block_malicious_packages() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "vulns": [ { "id": "MAL-462", "summary": "Malicious package" } ], + "next_page_token": null + }))) + .mount(&server) + .await; + + let _env = TempEnvVar::set("OSV_ENDPOINT", &format!("{}/v1/query", server.uri())); + for (command, package) in [ + (r"C:\Program Files\nodejs\NPX.CMD", "bad-npm-package"), + ("C:/Program Files/nodejs/nPx.CmD", "bad-npm-package"), + (r"C:\Users\user\.local\bin\UVX.EXE", "bad_pypi_package"), + ("C:/Users/user/.local/bin/uVx.ExE", "bad_pypi_package"), + ] { + let result = deny_if_malicious_cmd_args(command, &[package.to_string()]).await; + + assert!(result.is_err(), "expected {command} to query OSV"); + assert!(format!("{:?}", result.unwrap_err()).contains("MAL-462")); + } + } + + #[tokio::test] + #[serial_test::serial] + async fn cmd_args_path_qualified_commands_allow_clean_packages() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "vulns": [], + "next_page_token": null + }))) + .mount(&server) + .await; + + let _env = TempEnvVar::set("OSV_ENDPOINT", &format!("{}/v1/query", server.uri())); + for (command, package) in [ + ("/usr/local/bin/NPX", "clean-npm-package"), + ("/opt/venv/bin/UvX", "clean_pypi_package"), + ] { + let result = deny_if_malicious_cmd_args(command, &[package.to_string()]).await; + + assert!( + result.is_ok(), + "expected {command} to allow a clean package" + ); + } + + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + + #[tokio::test] + #[serial_test::serial] + async fn cmd_args_lookalike_commands_are_skipped() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "vulns": [ { "id": "MAL-462", "summary": "Malicious package" } ], + "next_page_token": null + }))) + .mount(&server) + .await; + + let _env = TempEnvVar::set("OSV_ENDPOINT", &format!("{}/v1/query", server.uri())); + for command in [ + "my-npx", + "npx.exe", + "npx.cmd.bak", + "/usr/bin/notnpx", + "uvx.cmd", + "uvx.exe.bak", + r"C:\Tools\my-uvx", + ] { + let result = + deny_if_malicious_cmd_args(command, &["malicious-package".to_string()]).await; + + assert!(result.is_ok(), "expected {command} to remain fail-open"); + } + + assert!(server.received_requests().await.unwrap().is_empty()); + } + #[tokio::test] #[serial_test::serial] async fn cmd_args_skip_flags_then_parse() {