diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 2fc929f4f6f15..e8b4e8baee431 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -30,6 +30,8 @@ use uv_torch::TorchMode; use uv_workspace::pyproject::{ExtraBuildDependencies, OverrideDependency}; use uv_workspace::pyproject_mut::AddBoundsKind; +use crate::{EnvironmentOptions, FilesystemOptions}; + /// A `pyproject.toml` with an (optional) `[tool.uv]` section. #[allow(dead_code)] #[derive(Debug, Clone, Default, Deserialize)] @@ -2799,6 +2801,26 @@ pub struct AddOptions { #[serde(rename_all = "kebab-case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct AuditOptions { + /// Whether to run the automatic malware check during sync operations. + #[option( + default = "false", + value_type = "bool", + example = r#" + malware-check = true + "# + )] + pub malware_check: Option, + + /// The vulnerability service URL to use for automatic malware checks. + #[option( + default = "\"https://api.osv.dev/\"", + value_type = "str", + example = r#" + malware-check-url = "https://example.com" + "# + )] + pub malware_check_url: Option, + /// A list of vulnerability IDs to ignore during auditing. /// /// Vulnerabilities matching any of the provided IDs (including aliases) will be excluded from @@ -2835,11 +2857,23 @@ pub struct MalwareCheckSettings { pub malware_check_url: Option, } -impl From<&crate::EnvironmentOptions> for MalwareCheckSettings { - fn from(options: &crate::EnvironmentOptions) -> Self { +impl MalwareCheckSettings { + pub fn resolve( + filesystem: Option<&FilesystemOptions>, + environment: &EnvironmentOptions, + ) -> Self { + let audit = filesystem.and_then(|options| options.audit.as_ref()); + Self { - enabled: options.malware_check.value == Some(true), - malware_check_url: options.malware_check_url.clone(), + enabled: environment + .malware_check + .value + .or(audit.and_then(|audit| audit.malware_check)) + .unwrap_or_default(), + malware_check_url: environment + .malware_check_url + .clone() + .or_else(|| audit.and_then(|audit| audit.malware_check_url.clone())), } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 7870400c71475..85beb635dadce 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -794,7 +794,7 @@ impl RunSettings { let show_resolution = show_resolution || environment.show_resolution.value == Some(true); let no_env_file = no_env_file || environment.no_env_file.value == Some(true); - let malware_settings = MalwareCheckSettings::from(&environment); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); Self { lock_check: resolve_lock_check(locked), @@ -1849,6 +1849,7 @@ impl SyncSettings { .map(|fs| fs.install_mirrors.clone()) .unwrap_or_default(); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); let settings = ResolverInstallerSettings::combine( resolver_installer_options(installer, build), filesystem, @@ -1926,8 +1927,6 @@ impl SyncSettings { let no_install_local = no_install_local.is_enabled(); let only_install_local = only_install_local.is_enabled(); - let malware_settings = MalwareCheckSettings::from(&environment); - Self { output_format, lock_check: resolve_lock_check(locked), @@ -2141,7 +2140,7 @@ impl MetadataSettings { // Check for conflicts between locked and frozen. check_conflicts(locked, frozen); - let malware_settings = MalwareCheckSettings::from(&environment); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); Self { script, @@ -2401,7 +2400,7 @@ impl AddSettings { let no_install_local = no_install_local.is_enabled(); let only_install_local = only_install_local.is_enabled(); - let malware_settings = MalwareCheckSettings::from(&environment); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); Self { lock_check: resolve_lock_check(locked), @@ -2532,7 +2531,7 @@ impl RemoveSettings { // Check for conflicts between no_sync and frozen. check_conflicts(no_sync, frozen); - let malware_settings = MalwareCheckSettings::from(&environment); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); Self { lock_check: resolve_lock_check(locked), @@ -2619,7 +2618,7 @@ impl VersionSettings { // Check for conflicts between no_sync and frozen. check_conflicts(no_sync, frozen); - let malware_settings = MalwareCheckSettings::from(&environment); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); Self { value, @@ -3056,13 +3055,12 @@ impl CheckSettings { Some(environment.dev), Some(environment.no_dev), ); + let malware_settings = MalwareCheckSettings::resolve(filesystem.as_ref(), &environment); let settings = ResolverInstallerSettings::combine( resolver_installer_options(installer, build), filesystem, &environment, ); - let malware_settings = MalwareCheckSettings::from(&environment); - Self { ty_path: environment.ty_path, script, diff --git a/crates/uv/tests/sync/sync.rs b/crates/uv/tests/sync/sync.rs index 568441c9ab1ab..5df826a598877 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -17189,22 +17189,25 @@ fn sync_reinstalls_on_version_change() -> Result<()> { #[tokio::test] async fn sync_malware_detected() { let context = uv_test::test_context!("3.12"); + let server = MockServer::start().await; let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml - .write_str(indoc! {r#" + .write_str(&formatdoc! {r#" [project] name = "project" version = "0.1.0" requires-python = ">=3.12" dependencies = ["iniconfig==2.0.0"] - "#}) + + [tool.uv.audit] + malware-check = true + malware-check-url = "{}" + "#, server.uri()}) .unwrap(); context.lock().assert().success(); - let server = MockServer::start().await; - Mock::given(method("POST")) .and(path("/v1/querybatch")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -17225,8 +17228,8 @@ async fn sync_malware_detected() { uv_snapshot!(context.filters(), context .sync() .arg("--preview-features").arg("malware-check") - .env(EnvVars::UV_MALWARE_CHECK, "1") - .env(EnvVars::UV_MALWARE_CHECK_URL, server.uri()), @" + .env_remove(EnvVars::UV_MALWARE_CHECK) + .env_remove(EnvVars::UV_MALWARE_CHECK_URL), @" success: false exit_code: 2 ----- stdout ----- @@ -17385,6 +17388,10 @@ async fn sync_malware_check_skips_non_mal() { version = "0.1.0" requires-python = ">=3.12" dependencies = ["iniconfig==2.0.0"] + + [tool.uv.audit] + malware-check = false + malware-check-url = "https://example.com" "#}) .unwrap(); @@ -17432,8 +17439,8 @@ async fn sync_malware_check_skips_non_mal() { "); } -/// Ensure that `UV_MALWARE_CHECK=0` keeps the malware check disabled even when the preview -/// feature is enabled. +/// Ensure that `UV_MALWARE_CHECK=0` keeps the malware check disabled even when enabled in user +/// configuration. #[tokio::test] async fn sync_malware_check_disabled() { let context = uv_test::test_context!("3.12"); @@ -17451,10 +17458,17 @@ async fn sync_malware_check_disabled() { context.lock().assert().success(); + let user_config_dir = context.user_config_dir.child("uv"); + user_config_dir.create_dir_all().unwrap(); + user_config_dir + .child("uv.toml") + .write_str("[audit]\nmalware-check = true") + .unwrap(); + let server = MockServer::start().await; - // Even though the preview feature is enabled, the check is explicitly disabled via env - // var so no request should be made. (No mocks are mounted, so any request would fail.) + // The check is explicitly disabled via env var, so no request should be made. (No mocks are + // mounted, so any request would fail.) uv_snapshot!(context.filters(), context .sync() diff --git a/docs/concepts/projects/sync.md b/docs/concepts/projects/sync.md index 4045c4e1d97e9..d48b3df008b03 100644 --- a/docs/concepts/projects/sync.md +++ b/docs/concepts/projects/sync.md @@ -233,4 +233,8 @@ against [OSV](https://osv.dev). OSV references MAL advisories from the OpenSSF's If a locked dependency matches a malware advisory, the sync will be terminated. -To enable malware checks, set `UV_MALWARE_CHECK=1` in your environment. +To enable malware checks, set `audit.malware-check = true` in your uv settings or set +`UV_MALWARE_CHECK=1` in your environment. + +To use an alternative vulnerability service, set `audit.malware-check-url` in your uv settings or +set `UV_MALWARE_CHECK_URL` in your environment. diff --git a/uv.schema.json b/uv.schema.json index 52671378cd6c8..e7dac716dc8d3 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -653,6 +653,21 @@ "items": { "type": "string" } + }, + "malware-check": { + "description": "Whether to run the automatic malware check during sync operations.", + "type": ["boolean", "null"] + }, + "malware-check-url": { + "description": "The vulnerability service URL to use for automatic malware checks.", + "anyOf": [ + { + "$ref": "#/definitions/DisplaySafeUrl" + }, + { + "type": "null" + } + ] } } },