diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 92a463565dc..dbc977dba06 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -5035,6 +5035,51 @@ pub struct CheckArgs { pub refresh: RefreshArgs, } +#[derive(Args)] +#[group(skip)] +pub struct AuditCommonArgs { + /// Select the output format. + #[arg(long, value_enum, default_value_t = AuditOutputFormat::default())] + pub output_format: AuditOutputFormat, + + /// Ignore a vulnerability by ID. + /// + /// Vulnerabilities matching any of the provided IDs (including aliases) will be excluded from + /// the audit results. + /// + /// May be provided multiple times. + #[arg(long)] + pub ignore: Vec, + + /// Ignore a vulnerability by ID, but only while no fix is available. + /// + /// Vulnerabilities matching any of the provided IDs (including aliases) will be excluded from + /// the audit results as long as they have no known fix versions. Once a fix version becomes + /// available, the vulnerability will be reported again. + /// + /// May be provided multiple times. + #[arg(long)] + pub ignore_until_fixed: Vec, + + /// The service format to use for vulnerability lookups. + /// + /// Each service format has a default URL, which can be + /// changed with `--service-url`. The defaults are: + /// + /// * OSV: + #[arg(long, value_enum, default_value = "osv")] + pub service_format: VulnerabilityServiceFormat, + + /// The URL to vulnerability service API endpoint. + /// + /// If not provided, the default URL for the selected service will be used. + /// + /// The service needs to use the OSV protocol, unless a different + /// format was requested by `--service-format`. + #[arg(long, value_hint = ValueHint::Url)] + pub service_url: Option, +} + #[derive(Args)] pub struct AuditArgs { /// Don't audit the specified optional dependencies. @@ -5091,9 +5136,8 @@ pub struct AuditArgs { #[arg(long, conflicts_with_all = ["locked", "upgrade", "no_sources"])] pub frozen: bool, - /// Select the output format. - #[arg(long, value_enum, default_value_t = AuditOutputFormat::default())] - pub output_format: AuditOutputFormat, + #[command(flatten)] + pub audit: AuditCommonArgs, #[command(flatten)] pub build: BuildOptionsArgs, @@ -5128,43 +5172,6 @@ pub struct AuditArgs { /// `aarch64-apple-darwin`. #[arg(long)] pub python_platform: Option, - - /// Ignore a vulnerability by ID. - /// - /// Vulnerabilities matching any of the provided IDs (including aliases) will be excluded from - /// the audit results. - /// - /// May be provided multiple times. - #[arg(long)] - pub ignore: Vec, - - /// Ignore a vulnerability by ID, but only while no fix is available. - /// - /// Vulnerabilities matching any of the provided IDs (including aliases) will be excluded from - /// the audit results as long as they have no known fix versions. Once a fix version becomes - /// available, the vulnerability will be reported again. - /// - /// May be provided multiple times. - #[arg(long)] - pub ignore_until_fixed: Vec, - - /// The service format to use for vulnerability lookups. - /// - /// Each service format has a default URL, which can be - /// changed with `--service-url`. The defaults are: - /// - /// * OSV: - #[arg(long, value_enum, default_value = "osv")] - pub service_format: VulnerabilityServiceFormat, - - /// The URL to vulnerability service API endpoint. - /// - /// If not provided, the default URL for the selected service will be used. - /// - /// The service needs to use the OSV protocol, unless a different - /// format was requested by `--service-format`. - #[arg(long, value_hint = ValueHint::Url)] - pub service_url: Option, } #[derive(Args)] @@ -5267,6 +5274,8 @@ pub enum ToolCommand { /// List installed tools. #[command(alias = "ls")] List(ToolListArgs), + /// Audit installed tools and their dependencies. + Audit(ToolAuditArgs), /// Uninstall a tool. Uninstall(ToolUninstallArgs), /// Ensure that the tool executable directory is on the `PATH`. @@ -5711,6 +5720,20 @@ pub struct ToolListArgs { pub no_python_downloads: bool, } +#[derive(Args)] +pub struct ToolAuditArgs { + /// The names of the installed tools to audit. + #[arg(required = true, value_hint = ValueHint::Other)] + pub name: Vec, + + /// Audit all installed tools. + #[arg(long, conflicts_with("name"))] + pub all: bool, + + #[command(flatten)] + pub audit: AuditCommonArgs, +} + #[derive(Args)] pub struct ToolDirArgs { /// Show the directory into which `uv tool` will install executables. diff --git a/crates/uv-dev/src/generate_preview_features_reference.rs b/crates/uv-dev/src/generate_preview_features_reference.rs index d1dcb927b3e..038a7133dc7 100644 --- a/crates/uv-dev/src/generate_preview_features_reference.rs +++ b/crates/uv-dev/src/generate_preview_features_reference.rs @@ -108,7 +108,7 @@ mod tests { assert_snapshot!(generate(), @r" - `add-bounds`: Allows configuring the [default bounds for `uv add`](../reference/settings.md#add-bounds) invocations. - `adjust-ulimit`: On Unix, raises the process's soft open-file limit at startup, up to the hard limit. - - `audit-command`: Allows using `uv audit`. + - `audit-command`: Allows using `uv audit` and `uv tool audit`. - `auth-helper`: Allows using `uv auth helper` as a credential helper for external tools. - `azure-endpoint`: Allows signing requests to Azure Blob Storage endpoints with Azure credentials. - `cache-size`: Allows using `uv cache size`. @@ -152,8 +152,8 @@ mod tests { effect before configuration is loaded. - `toml-backwards-compatibility`: Rewrites `pyproject.toml` as TOML 1.0 when building source distributions, preserving the original as `pyproject.toml.orig` to ensure compatibility with older build tools. - - `tool-install-locks`: Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations - and upgrades. + - `tool-install-locks`: Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations, + upgrades, and audits. - `venv-safe-clear`: Prevents `uv venv --clear` from clearing a directory that does not contain a `pyvenv.cfg` file unless `--force` is provided. - `workspace-dir`: Allows using `uv workspace dir`. diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 0eda60a7962..c2f5b0c191e 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -284,7 +284,7 @@ pub enum PreviewFeature { /// Requires normalized distribution filenames when publishing, skipping files whose names are /// not normalized. PublishRequireNormalized, - /// Allows using `uv audit`. + /// Allows using `uv audit` and `uv tool audit`. #[preview(alias = "audit")] AuditCommand, /// Rejects an invalid `--project` path instead of warning and continuing. Except for `uv init`, @@ -313,8 +313,8 @@ pub enum PreviewFeature { /// Stores [project virtual environments](./projects/layout.md#centralized-project-environments) /// in the uv cache. CentralizedProjectEnvs, - /// Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations - /// and upgrades. + /// Stores a `uv.lock` alongside each installed tool and reuses it for reproducible installations, + /// upgrades, and audits. ToolInstallLocks, /// Allows using `uv workspace list --scripts`. WorkspaceListScripts, diff --git a/crates/uv-test/src/lib.rs b/crates/uv-test/src/lib.rs index 680698b36bd..1ae16e9bb3c 100755 --- a/crates/uv-test/src/lib.rs +++ b/crates/uv-test/src/lib.rs @@ -1580,6 +1580,14 @@ impl TestContext { command } + /// Create a `uv tool audit` command with options shared across scenarios. + pub fn tool_audit(&self) -> Command { + let mut command = self.new_command(); + command.arg("tool").arg("audit"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv tool dir` command with options shared across scenarios. pub fn tool_dir(&self) -> Command { let mut command = self.new_command(); diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 6048efdc25b..4cc40dda5e7 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -56,6 +56,7 @@ pub(crate) use python::uninstall::uninstall as python_uninstall; pub(crate) use python::update_shell::update_shell as python_update_shell; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; +pub(crate) use tool::audit::audit as tool_audit; pub(crate) use tool::dir::dir as tool_dir; pub(crate) use tool::install::install as tool_install; pub(crate) use tool::list::list as tool_list; diff --git a/crates/uv/src/commands/project/audit.rs b/crates/uv/src/commands/project/audit.rs index 387be8ce0ef..bb3d9d7d55f 100644 --- a/crates/uv/src/commands/project/audit.rs +++ b/crates/uv/src/commands/project/audit.rs @@ -27,20 +27,24 @@ use uv_audit::{ use uv_cache::Cache; use uv_cli::AuditOutputFormat; use uv_client::{BaseClientBuilder, CachedClient, RegistryClientBuilder}; -use uv_configuration::{Concurrency, DependencyGroups, ExtrasSpecification, TargetTriple}; +use uv_configuration::{ + Concurrency, DependencyGroups, DependencyGroupsWithDefaults, ExtrasSpecification, + ExtrasSpecificationWithDefaults, TargetTriple, +}; use uv_distribution_types::{IndexCapabilities, IndexUrl}; use uv_fs::{CWD, find_git_repository_root, relative_to}; use uv_normalize::{DefaultExtras, DefaultGroups}; use uv_preview::{Preview, PreviewFeature}; use uv_python::{ConfigDiscovery, PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; +use uv_resolver::Lock; use uv_scripts::Pep723Script; use uv_settings::PythonInstallMirrors; use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; -mod json; -mod sarif; +pub(crate) mod json; +pub(crate) mod sarif; pub(crate) async fn audit( project_dir: &Path, @@ -214,15 +218,79 @@ pub(crate) async fn audit( ) }); - // Build the set of auditable packages by traversing the lockfile from workspace roots, - // respecting the user's extras and dependency-group filters. Workspace members are excluded - // (they are local and have no external package identity), as are packages without a version. - // The `Auditable` view offers per-version and per-project projections from a single walk. - let auditable = lock.auditable(&extras, &groups, |_| true); - let mut projects = auditable.projects(target.install_path())?; + let outcome = audit_lock( + &lock, + target.install_path(), + &extras, + &groups, + &settings, + client_builder, + concurrency, + &cache, + printer, + service, + service_url, + &ignore, + &ignore_until_fixed, + ) + .await?; + + warn_unmatched_ignores( + &ignore, + &ignore_until_fixed, + &outcome.matched_ignores, + "the project", + ); + + let display = AuditResults { + printer, + n_packages: outcome.n_packages, + output_format, + findings: outcome.findings, + artifact_uri: { + let lock_path = target.lock_path(); + // If we've run `uv audit --script`, we might only have an in-memory lockfile. + // In that case, use the script's own path as the artifact path. + let artifact_path = if let LockTarget::Script(script) = target + && !lock_path.is_file() + { + script.path.as_path() + } else { + lock_path.as_path() + }; + artifact_uri(artifact_path) + }, + }; + display.render() +} + +/// Audit findings and ignore-rule matches for one lockfile. +pub(crate) struct AuditOutcome { + pub(crate) n_packages: usize, + pub(crate) findings: Vec, + pub(crate) matched_ignores: FxHashSet, +} + +/// Audit the dependency graph reachable from a project, script, or tool lockfile. +pub(crate) async fn audit_lock( + lock: &Lock, + root: &Path, + extras: &ExtrasSpecificationWithDefaults, + groups: &DependencyGroupsWithDefaults, + settings: &ResolverSettings, + client_builder: BaseClientBuilder<'_>, + concurrency: Concurrency, + cache: &Cache, + printer: Printer, + service: VulnerabilityServiceFormat, + service_url: Option, + ignore: &[VulnerabilityID], + ignore_until_fixed: &[VulnerabilityID], +) -> Result { + let auditable = lock.auditable(extras, groups, |_| true); + let mut projects = auditable.projects(root)?; - // Drop projects whose index is configured as flat, since we know we won't - // find PEP 792 statuses. + // Flat indexes cannot provide PEP 792 project-status metadata. let flat_index_urls: FxHashSet<&IndexUrl> = settings .index_locations .flat_indexes() @@ -230,14 +298,12 @@ pub(crate) async fn audit( .collect(); projects.retain(|(_, url)| !flat_index_urls.contains(url)); - // Perform the audit. let reporter = AuditReporter::from(printer); let dependencies: Vec = auditable .packages() .map(|(name, version)| Dependency::new(name.clone(), version.clone())) .collect(); let base_client = client_builder.clone().build()?; - let registry_client = RegistryClientBuilder::new(client_builder, cache.clone()) .index_locations(settings.index_locations.clone()) .keyring(settings.keyring_provider) @@ -264,27 +330,24 @@ pub(crate) async fn audit( status_audit.query_batch(&projects).await }; let (osv_findings, status_findings) = tokio::join!(osv_future, status_future); - let mut all_findings = osv_findings?; - all_findings.extend(status_findings); - + let mut findings = osv_findings?; + findings.extend(status_findings); reporter.on_audit_complete(); - // Filter out ignored vulnerabilities, tracking how many were ignored - // and which ignore rules actually matched. - let mut matched_ignores: FxHashSet<&VulnerabilityID> = FxHashSet::default(); - let all_findings: Vec<_> = all_findings + let mut matched_ignores = FxHashSet::default(); + let findings = findings .into_iter() .filter(|finding| match finding { Finding::Vulnerability(vulnerability) => { if let Some(id) = ignore.iter().find(|id| vulnerability.matches(id)) { - matched_ignores.insert(id); + matched_ignores.insert(id.clone()); return false; } if let Some(id) = ignore_until_fixed .iter() .find(|id| vulnerability.matches(id)) { - matched_ignores.insert(id); + matched_ignores.insert(id.clone()); if vulnerability.fix_versions.is_empty() { return false; } @@ -295,61 +358,54 @@ pub(crate) async fn audit( }) .collect(); - // Warn about ignore rules that didn't match any vulnerability. + Ok(AuditOutcome { + n_packages: auditable.len(), + findings, + matched_ignores, + }) +} + +/// Warn once for each ignore rule that did not match an audited vulnerability. +pub(crate) fn warn_unmatched_ignores( + ignore: &[VulnerabilityID], + ignore_until_fixed: &[VulnerabilityID], + matched_ignores: &FxHashSet, + scope: &str, +) { for id in ignore.iter().chain(ignore_until_fixed.iter()) { if !matched_ignores.contains(id) { warn_user!( - "Ignored vulnerability `{}` does not match any vulnerability in the project", + "Ignored vulnerability `{}` does not match any vulnerability in {scope}", id.as_str() ); } } +} - let display = AuditResults { - printer, - n_packages: auditable.len(), - output_format, - findings: all_findings, - artifact_uri: { - let lock_path = target.lock_path(); - // If we've run `uv audit --script`, we might only have an in-memory lockfile. - // In that case, use the script's own path as the artifact path. - let artifact_path = if let LockTarget::Script(script) = target - && !lock_path.is_file() - { - script.path.as_path() - } else { - lock_path.as_path() - }; - // SARIF consumers resolve artifact locations from the repository root, regardless of - // the directory from which uv was invoked. Fall back to the invocation directory for - // projects that aren't in a Git repository. - let artifact_path = if let Some(repository_root) = - find_git_repository_root(artifact_path) - && let Ok(relative) = relative_to(artifact_path, repository_root) - { - relative - } else if let Ok(relative) = artifact_path.strip_prefix(&*CWD) { - relative.to_path_buf() - } else { - artifact_path.to_path_buf() - }; - artifact_path.to_string_lossy().replace('\\', "/") - }, +/// Resolve a lockfile path into the URI used by SARIF consumers. +pub(crate) fn artifact_uri(path: &Path) -> String { + let path = if let Some(repository_root) = find_git_repository_root(path) + && let Ok(relative) = relative_to(path, repository_root) + { + relative + } else if let Ok(relative) = path.strip_prefix(&*CWD) { + relative.to_path_buf() + } else { + path.to_path_buf() }; - display.render() + path.to_string_lossy().replace('\\', "/") } -struct AuditResults { - printer: Printer, - n_packages: usize, - output_format: AuditOutputFormat, - findings: Vec, - artifact_uri: String, +pub(crate) struct AuditResults { + pub(crate) printer: Printer, + pub(crate) n_packages: usize, + pub(crate) output_format: AuditOutputFormat, + pub(crate) findings: Vec, + pub(crate) artifact_uri: String, } impl AuditResults { - fn render(&self) -> Result { + pub(crate) fn render(&self) -> Result { match self.output_format { AuditOutputFormat::Text => self.render_text(), AuditOutputFormat::Json => self.render_json(), @@ -366,7 +422,7 @@ impl AuditResults { }) } - fn exit_status(&self) -> ExitStatus { + pub(crate) fn exit_status(&self) -> ExitStatus { // NOTE: intentional: we don't currently fail if there are any adverse statuses, // only when there are vulnerabilities. We will likely change this once we allow users // to ignore adverse statuses and configure policies. diff --git a/crates/uv/src/commands/project/audit/json.rs b/crates/uv/src/commands/project/audit/json.rs index d4424b7f469..4fdae14b5fc 100644 --- a/crates/uv/src/commands/project/audit/json.rs +++ b/crates/uv/src/commands/project/audit/json.rs @@ -1,10 +1,19 @@ //! JSON layout models for `uv audit`. use serde::Serialize; +use uv_normalize::PackageName; + +use super::AuditResults; #[derive(Debug, Serialize)] pub(crate) struct Report { schema: Schema, + #[serde(flatten)] + body: ReportBody, +} + +#[derive(Debug, Serialize)] +struct ReportBody { summary: Summary, vulnerabilities: Vec, adverse_statuses: Vec, @@ -44,17 +53,55 @@ impl Report { Self { schema: Schema::default(), - summary: Summary { - audited_packages: n_packages, - vulnerabilities: vulnerabilities.len(), - adverse_statuses: adverse_statuses.len(), + body: ReportBody { + summary: Summary { + audited_packages: n_packages, + vulnerabilities: vulnerabilities.len(), + adverse_statuses: adverse_statuses.len(), + }, + vulnerabilities, + adverse_statuses, }, - vulnerabilities, - adverse_statuses, } } } +/// JSON report containing separate findings for each audited tool. +#[derive(Debug, Serialize)] +pub(crate) struct ToolReports { + schema: Schema, + tools: Vec, +} + +impl ToolReports { + pub(crate) fn from_audits(audits: &[(PackageName, AuditResults)]) -> Self { + let tools = audits + .iter() + .map(|(name, results)| { + let (vulnerabilities, statuses) = results.split_findings(); + let report = Report::from_findings(results.n_packages, &vulnerabilities, &statuses); + + ToolReport { + name: name.to_string(), + body: report.body, + } + }) + .collect(); + + Self { + schema: Schema::default(), + tools, + } + } +} + +#[derive(Debug, Serialize)] +struct ToolReport { + name: String, + #[serde(flatten)] + body: ReportBody, +} + #[derive(Debug, Serialize, Default)] struct Schema { version: SchemaVersion, diff --git a/crates/uv/src/commands/project/audit/sarif.rs b/crates/uv/src/commands/project/audit/sarif.rs index 398c388b0e7..364c7ec73e0 100644 --- a/crates/uv/src/commands/project/audit/sarif.rs +++ b/crates/uv/src/commands/project/audit/sarif.rs @@ -9,6 +9,9 @@ use std::collections::BTreeMap; use serde::Serialize; use serde_json::Value; use uv_audit::{AdverseStatus, ProjectStatus, Vulnerability}; +use uv_normalize::PackageName; + +use super::AuditResults; /// Top-level SARIF log object (SARIF §3.13). #[derive(Debug, Serialize)] @@ -69,6 +72,7 @@ impl Report { "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json" .to_string(), runs: vec![Run { + automation_details: None, invocations: vec![Invocation { execution_successful: true, }], @@ -87,17 +91,52 @@ impl Report { version: "2.1.0".to_string(), } } + + /// Combine tool findings into one SARIF document, retaining a run for each tool. + pub(crate) fn from_audits(audits: &[(PackageName, AuditResults)]) -> Self { + let mut report = Self::from_findings(&[], &[], ""); + report.runs.clear(); + + for (name, results) in audits { + let (vulnerabilities, statuses) = results.split_findings(); + let runs = Self::from_findings(&vulnerabilities, &statuses, &results.artifact_uri) + .runs + .into_iter() + .map(|mut run| { + run.automation_details = Some(RunAutomationDetails { + id: RunId(format!("uv/tool-audit/{name}")), + }); + run + }); + report.runs.extend(runs); + } + + report + } } /// A single tool invocation's results (SARIF §3.14). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct Run { + #[serde(skip_serializing_if = "Option::is_none")] + automation_details: Option, invocations: Vec, results: Vec, tool: Tool, } +/// Stable automation identity for an individual audited tool. +#[derive(Debug, Serialize)] +struct RunAutomationDetails { + id: RunId, +} + +/// Stable identity for a SARIF run. +#[derive(Debug, Serialize)] +#[serde(transparent)] +struct RunId(String); + /// Tool metadata wrapper (SARIF §3.18). #[derive(Debug, Serialize)] struct Tool { diff --git a/crates/uv/src/commands/tool/audit.rs b/crates/uv/src/commands/tool/audit.rs new file mode 100644 index 00000000000..7e0572529ee --- /dev/null +++ b/crates/uv/src/commands/tool/audit.rs @@ -0,0 +1,288 @@ +use std::fmt::Write as _; +use std::io; + +use anyhow::{Result, bail}; +use rustc_hash::FxHashSet; + +use uv_audit::{VulnerabilityID, VulnerabilityServiceFormat}; +use uv_cache::Cache; +use uv_cli::AuditOutputFormat; +use uv_client::BaseClientBuilder; +use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, ExtrasSpecification}; +use uv_fs::Simplified; +use uv_normalize::{DefaultExtras, PackageName}; +use uv_preview::{Preview, PreviewFeature}; +use uv_redacted::DisplaySafeUrl; +use uv_resolver::{Lock, LockParseError}; +use uv_settings::{Combine, ResolverInstallerOptions}; +use uv_tool::InstalledTools; +use uv_warnings::warn_user; + +use crate::commands::ExitStatus; +use crate::commands::project::audit::{ + AuditResults, artifact_uri, audit_lock, json, sarif, warn_unmatched_ignores, +}; +use crate::printer::Printer; +use crate::settings::ResolverInstallerSettings; + +/// Audit selected installed tools, or every installed tool if no names are provided. +pub(crate) async fn audit( + names: Vec, + output_format: AuditOutputFormat, + service: VulnerabilityServiceFormat, + service_url: Option, + ignore: Vec, + ignore_until_fixed: Vec, + filesystem: ResolverInstallerOptions, + client_builder: BaseClientBuilder<'_>, + concurrency: Concurrency, + cache: &Cache, + printer: Printer, + preview: Preview, +) -> Result { + let mut missing_features = Vec::new(); + if !preview.is_enabled(PreviewFeature::AuditCommand) { + missing_features.push("audit"); + } + if !preview.is_enabled(PreviewFeature::ToolInstallLocks) { + missing_features.push("tool-install-locks"); + } + if !missing_features.is_empty() { + warn_user!( + "`uv tool audit` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + missing_features.join(",") + ); + } + if matches!(output_format, AuditOutputFormat::Json) + && !preview.is_enabled(PreviewFeature::JsonOutput) + { + warn_user!( + "The `--output-format json` option is experimental and the schema may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeature::JsonOutput + ); + } + + let installed_tools = InstalledTools::from_settings()?; + let _lock = match installed_tools.lock().await { + Ok(lock) => lock, + Err(error) + if error + .as_io_error() + .is_some_and(|error| error.kind() == io::ErrorKind::NotFound) => + { + if let Some(name) = names.first() { + bail!("`{name}` is not installed; run `uv tool install {name}` to install"); + } + if matches!(output_format, AuditOutputFormat::Text) { + writeln!(printer.stderr(), "No tools installed")?; + return Ok(ExitStatus::Success); + } + return render_audits(&[], output_format, printer); + } + Err(error) => return Err(error.into()), + }; + + let explicit_tool = !names.is_empty(); + let mut tools = if names.is_empty() { + installed_tools.tools()? + } else { + let mut tools = Vec::with_capacity(names.len()); + for name in names { + match installed_tools.get_tool_receipt(&name) { + Ok(Some(tool)) => tools.push((name, Ok(tool))), + Ok(None) => { + bail!("`{name}` is not installed; run `uv tool install {name}` to install"); + } + Err(error) => { + bail!("Tool `{name}` has an invalid receipt: {error}"); + } + } + } + tools + }; + tools.sort_by(|(left, _), (right, _)| left.cmp(right)); + tools.dedup_by(|(left, _), (right, _)| left == right); + + if tools.is_empty() { + if matches!(output_format, AuditOutputFormat::Text) { + writeln!(printer.stderr(), "No tools installed")?; + return Ok(ExitStatus::Success); + } + return render_audits(&[], output_format, printer); + } + + let extras = ExtrasSpecification::default().with_defaults(DefaultExtras::default()); + let groups = DependencyGroupsWithDefaults::none(); + let mut audits = Vec::new(); + let mut matched_ignores = FxHashSet::default(); + + for (name, tool) in tools { + let tool = match tool { + Ok(tool) => tool, + Err(error) => { + if explicit_tool { + bail!("Tool `{name}` has an invalid receipt: {error}"); + } + warn_user!( + "Ignoring malformed tool `{name}` (run `uv tool uninstall {name}` to remove)" + ); + continue; + } + }; + + let root = installed_tools.tool_dir(&name); + let lock_path = root.join("uv.lock"); + let contents = match fs_err::read_to_string(&lock_path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if explicit_tool { + bail!( + "Tool `{name}` does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it" + ); + } + warn_user!( + "Skipping tool `{name}` because it does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it" + ); + continue; + } + Err(error) => { + if explicit_tool { + bail!( + "Failed to read the lockfile for tool `{name}` at `{}`: {error}", + lock_path.user_display() + ); + } + warn_user!( + "Skipping tool `{name}` because its lockfile at `{}` could not be read: {error}", + lock_path.user_display() + ); + continue; + } + }; + let lock = match Lock::from_toml(&contents) { + Ok(lock) => lock, + Err( + LockParseError::UnsupportedVersion { supported, version } + | LockParseError::UnparsableVersion { + supported, version, .. + }, + ) => { + if explicit_tool { + bail!( + "The lockfile for tool `{name}` at `{}` uses an unsupported schema version (v{version}, but only v{supported} is supported)", + lock_path.user_display() + ); + } + warn_user!( + "Skipping tool `{name}` because its lockfile at `{}` uses an unsupported schema version (v{version}, but only v{supported} is supported)", + lock_path.user_display() + ); + continue; + } + Err(LockParseError::Toml(error)) => { + if explicit_tool { + bail!( + "Failed to parse the lockfile for tool `{name}` at `{}`: {error}", + lock_path.user_display() + ); + } + warn_user!( + "Skipping tool `{name}` because its lockfile at `{}` is invalid: {error}", + lock_path.user_display() + ); + continue; + } + }; + + let settings = ResolverInstallerSettings::from( + ResolverInstallerOptions::from(tool.options().clone()).combine(filesystem.clone()), + ); + let outcome = audit_lock( + &lock, + &root, + &extras, + &groups, + &settings.resolver, + client_builder.clone(), + concurrency.clone(), + cache, + printer, + service, + service_url.clone(), + &ignore, + &ignore_until_fixed, + ) + .await?; + + matched_ignores.extend(outcome.matched_ignores); + audits.push(( + name, + AuditResults { + printer, + n_packages: outcome.n_packages, + output_format, + findings: outcome.findings, + artifact_uri: artifact_uri(&lock_path), + }, + )); + } + + warn_unmatched_ignores( + &ignore, + &ignore_until_fixed, + &matched_ignores, + "the selected tools", + ); + + if audits.is_empty() && matches!(output_format, AuditOutputFormat::Text) { + writeln!(printer.stderr(), "No auditable tools installed")?; + return Ok(ExitStatus::Success); + } + + render_audits(&audits, output_format, printer) +} + +fn render_audits( + audits: &[(PackageName, AuditResults)], + output_format: AuditOutputFormat, + printer: Printer, +) -> Result { + match output_format { + AuditOutputFormat::Text => { + for (name, results) in audits { + writeln!(printer.stderr(), "Auditing `{name}`")?; + if !results.findings.is_empty() { + writeln!(printer.stdout_important(), "Tool `{name}`:")?; + } + results.render()?; + } + } + AuditOutputFormat::Json => { + let report = json::ToolReports::from_audits(audits); + writeln!( + printer.stdout_important(), + "{}", + serde_json::to_string_pretty(&report)? + )?; + } + AuditOutputFormat::Sarif => { + let report = sarif::Report::from_audits(audits); + writeln!( + printer.stdout_important(), + "{}", + serde_json::to_string_pretty(&report)? + )?; + } + } + + Ok( + if audits + .iter() + .any(|(_, results)| matches!(results.exit_status(), ExitStatus::Failure)) + { + ExitStatus::Failure + } else { + ExitStatus::Success + }, + ) +} diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index 23ae03a6456..38c893138c3 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -6,6 +6,7 @@ use uv_normalize::{ExtraName, PackageName}; use uv_pep440::Version; use uv_python::PythonRequest; +pub(crate) mod audit; pub(crate) mod common; pub(crate) mod dir; pub(crate) mod install; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index a8371114396..be22f535b46 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1744,6 +1744,30 @@ pub async fn run(cli: Cli, global_initialization: GlobalInitialization) -> Resul ) .await } + Commands::Tool(ToolNamespace { + command: ToolCommand::Audit(args), + }) => { + let args = settings::ToolAuditSettings::resolve(args, filesystem); + show_settings!(args); + + let cache = cache.init().await?; + + commands::tool_audit( + args.names, + args.output_format, + args.service_format, + args.service_url, + args.ignore, + args.ignore_until_fixed, + args.filesystem, + client_builder.subcommand(vec!["tool".to_owned(), "audit".to_owned()]), + globals.concurrency, + &cache, + printer, + globals.preview, + ) + .await + } Commands::Tool(ToolNamespace { command: ToolCommand::Upgrade(args), }) => { diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 0325d8bd8f7..d81f9c4e9d0 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -14,14 +14,14 @@ use uv_auth::Service; use uv_cache::{CacheArgs, Refresh}; use uv_cli::comma::CommaSeparatedRequirements; use uv_cli::{ - AddArgs, AuditArgs, AuditOutputFormat, AuthLoginArgs, AuthLogoutArgs, AuthTokenArgs, - ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, MetadataArgs, - PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, - PipSyncArgs, PipTreeArgs, PipUninstallArgs, ProjectDependencyGroupsArgs, PythonFindArgs, - PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, PythonUninstallArgs, - PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, SyncFormat, ToolDirArgs, ToolInstallArgs, - ToolListArgs, ToolRunArgs, ToolUninstallArgs, TreeArgs, TreeFormat, UpgradeArgs, VenvArgs, - VersionArgs, VersionBumpSpec, VersionFormat, + AddArgs, AuditArgs, AuditCommonArgs, AuditOutputFormat, AuthLoginArgs, AuthLogoutArgs, + AuthTokenArgs, ColorChoice, ExternalCommand, GlobalArgs, InitArgs, ListFormat, LockArgs, Maybe, + MetadataArgs, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, + PipShowArgs, PipSyncArgs, PipTreeArgs, PipUninstallArgs, ProjectDependencyGroupsArgs, + PythonFindArgs, PythonInstallArgs, PythonListArgs, PythonListFormat, PythonPinArgs, + PythonUninstallArgs, PythonUpgradeArgs, RemoveArgs, RunArgs, SyncArgs, SyncFormat, + ToolAuditArgs, ToolDirArgs, ToolInstallArgs, ToolListArgs, ToolRunArgs, ToolUninstallArgs, + TreeArgs, TreeFormat, UpgradeArgs, VenvArgs, VersionArgs, VersionBumpSpec, VersionFormat, }; use uv_cli::{ AuthorFrom, BuildArgs, BuildOptionsArgs, CheckArgs, ExcludeNewerArgs, ExportArgs, FormatArgs, @@ -1291,6 +1291,66 @@ impl ToolListSettings { } } +/// The resolved settings to use for a `tool audit` invocation. +#[derive(Debug, Clone)] +pub(crate) struct ToolAuditSettings { + pub(crate) names: Vec, + pub(crate) output_format: AuditOutputFormat, + pub(crate) service_format: VulnerabilityServiceFormat, + pub(crate) service_url: Option, + pub(crate) ignore: Vec, + pub(crate) ignore_until_fixed: Vec, + pub(crate) filesystem: ResolverInstallerOptions, +} + +impl ToolAuditSettings { + /// Resolve the [`ToolAuditSettings`] from the CLI and user-level configuration. + pub(crate) fn resolve(args: ToolAuditArgs, filesystem: Option) -> Self { + let ToolAuditArgs { + name, + all, + audit: + AuditCommonArgs { + output_format, + ignore, + ignore_until_fixed, + service_format, + service_url, + }, + } = args; + + let audit = filesystem + .as_ref() + .and_then(|options| options.audit.clone()) + .unwrap_or_default(); + let filesystem = filesystem + .map(FilesystemOptions::into_options) + .map(|options| ResolverInstallerOptions::from(options.top_level)) + .unwrap_or_default(); + + let ignore = ignore + .into_iter() + .chain(audit.ignore.unwrap_or_default()) + .map(VulnerabilityID::new) + .collect(); + let ignore_until_fixed = ignore_until_fixed + .into_iter() + .chain(audit.ignore_until_fixed.unwrap_or_default()) + .map(VulnerabilityID::new) + .collect(); + + Self { + names: if all { vec![] } else { name }, + output_format, + service_format, + service_url, + ignore, + ignore_until_fixed, + filesystem, + } + } +} + /// The resolved settings to use for a `tool uninstall` invocation. #[derive(Debug, Clone)] pub(crate) struct ToolUninstallSettings { @@ -3127,13 +3187,16 @@ impl AuditSettings { python_platform, locked, frozen, - output_format, + audit: + AuditCommonArgs { + output_format, + ignore, + ignore_until_fixed, + service_format, + service_url, + }, build, resolver, - ignore, - ignore_until_fixed, - service_format, - service_url, } = args; let filesystem_install_mirrors = filesystem diff --git a/crates/uv/tests/tool/main.rs b/crates/uv/tests/tool/main.rs index 5bcd80db6f0..6c4298678f5 100644 --- a/crates/uv/tests/tool/main.rs +++ b/crates/uv/tests/tool/main.rs @@ -3,6 +3,9 @@ #[cfg(all(feature = "test-python", feature = "test-pypi"))] use uv_test::pypi_proxy; +#[cfg(all(feature = "test-python", feature = "test-pypi"))] +mod tool_audit; + #[cfg(all(feature = "test-python", feature = "test-pypi"))] mod tool_dir; diff --git a/crates/uv/tests/tool/tool_audit.rs b/crates/uv/tests/tool/tool_audit.rs new file mode 100644 index 00000000000..4289a29cde9 --- /dev/null +++ b/crates/uv/tests/tool/tool_audit.rs @@ -0,0 +1,957 @@ +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::prelude::*; +use indoc::indoc; +use insta::assert_json_snapshot; +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use uv_static::EnvVars; +use uv_test::{TestContext, uv_snapshot}; + +fn install_tool(context: &TestContext, name: &str, locked: bool) { + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let links = context.workspace_root.join("test/links"); + + let mut command = context.tool_install(); + command + .arg(name) + .arg("--no-index") + .arg("--find-links") + .arg(links) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()); + if locked { + command.env(EnvVars::UV_PREVIEW_FEATURES, "tool-install-locks"); + } + command.assert().success(); +} + +async fn mount_clean_service(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{ "vulns": [] }] + }))) + .mount(server) + .await; +} + +async fn mount_vulnerable_service(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{ "vulns": [{ "id": "PYSEC-2026-0001" }] }] + }))) + .mount(server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/PYSEC-2026-0001")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "PYSEC-2026-0001", + "modified": "2026-01-01T00:00:00Z", + "summary": "A test vulnerability in simple-launcher", + "affected": [{ + "package": { + "ecosystem": "PyPI", + "name": "simple-launcher" + }, + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + { "introduced": "0" }, + { "fixed": "0.2.0" } + ] + }] + }], + "references": [{ + "type": "ADVISORY", + "url": "https://example.com/advisory/PYSEC-2026-0001" + }] + }))) + .mount(server) + .await; +} + +#[test] +fn tool_audit_requires_selection() { + let context = uv_test::test_context!("3.12"); + + uv_snapshot!(context.filters(), context.tool_audit(), @" + exit_code: 2 (failure) + ----- stderr ----- + error: the following required arguments were not provided: + ... + + Usage: uv tool audit --cache-dir [CACHE_DIR] ... + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("simple-launcher"), @" + exit_code: 2 (failure) + ----- stderr ----- + error: the argument '--all' cannot be used with '...' + + Usage: uv tool audit --cache-dir [CACHE_DIR] --all ... + + For more information, try '--help'. + "); +} + +#[test] +fn tool_audit_preview_features() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: `uv tool audit` is experimental and may change without warning. Pass `--preview-features audit,tool-install-locks` to disable this warning. + No tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: `uv tool audit` is experimental and may change without warning. Pass `--preview-features tool-install-locks` to disable this warning. + No tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: `uv tool audit` is experimental and may change without warning. Pass `--preview-features audit` to disable this warning. + No tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + No tools installed + "); +} + +#[test] +fn tool_audit_unknown_tool() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: `simple-launcher` is not installed; run `uv tool install simple-launcher` to install + "); +} + +#[test] +fn tool_audit_missing_lockfile() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", false); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Skipping tool `simple-launcher` because it does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it + No auditable tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Tool `simple-launcher` does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it + "); +} + +#[test] +fn tool_audit_invalid_receipt() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + fs_err::write( + tool_dir.join("simple-launcher").join("uv-receipt.toml"), + "not valid toml", + )?; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Ignoring malformed tool `simple-launcher` (run `uv tool uninstall simple-launcher` to remove) + No auditable tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Tool `simple-launcher` has an invalid receipt: Failed to read `uv-receipt.toml` at [TEMP_DIR]/tools/simple-launcher/uv-receipt.toml + "); + + Ok(()) +} + +#[test] +fn tool_audit_invalid_lockfile() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + fs_err::write( + tool_dir.join("simple-launcher").join("uv.lock"), + "not valid toml", + )?; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Skipping tool `simple-launcher` because its lockfile at `tools/simple-launcher/uv.lock` is invalid: TOML parse error at line 1, column 5 + | + 1 | not valid toml + | ^ + key with no value, expected `=` + + No auditable tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Failed to parse the lockfile for tool `simple-launcher` at `tools/simple-launcher/uv.lock`: TOML parse error at line 1, column 5 + | + 1 | not valid toml + | ^ + key with no value, expected `=` + "); + + Ok(()) +} + +#[test] +fn tool_audit_unsupported_lockfile_version() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let lock_path = tool_dir.join("simple-launcher").join("uv.lock"); + let contents = fs_err::read_to_string(&lock_path)?; + fs_err::write( + &lock_path, + contents.replacen("version = 1\n", "version = 2\n", 1), + )?; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Skipping tool `simple-launcher` because its lockfile at `tools/simple-launcher/uv.lock` uses an unsupported schema version (v2, but only v1 is supported) + No auditable tools installed + "); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: The lockfile for tool `simple-launcher` at `tools/simple-launcher/uv.lock` uses an unsupported schema version (v2, but only v1 is supported) + "); + + Ok(()) +} + +#[test] +fn tool_audit_unparsable_unsupported_lockfile_version() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let lock_path = tool_dir.join("simple-launcher").join("uv.lock"); + let contents = fs_err::read_to_string(&lock_path)? + .replacen("version = 1\n", "version = 2\n", 1) + .replacen("version = \"0.1.0\"\n", "version = false\n", 1); + fs_err::write(&lock_path, contents)?; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 2 (failure) + ----- stderr ----- + error: The lockfile for tool `simple-launcher` at `tools/simple-launcher/uv.lock` uses an unsupported schema version (v2, but only v1 is supported) + "); + + Ok(()) +} + +#[tokio::test] +async fn tool_audit_one_tool() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_all_tools() { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + install_tool(&context, "basic-app", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + Auditing `basic-app` + Found no known vulnerabilities and no adverse project statuses in 1 package + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_multiple_tools() { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + install_tool(&context, "basic-app", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .arg("basic-app") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + Auditing `basic-app` + Found no known vulnerabilities and no adverse project statuses in 1 package + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_mixed_lockfiles() { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + install_tool(&context, "basic-app", false); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Skipping tool `basic-app` because it does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_shared_dependencies() { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let links = context.workspace_root.join("test/links"); + + context + .tool_install() + .arg("simple-launcher") + .arg("--with") + .arg("basic-app") + .arg("--no-index") + .arg("--find-links") + .arg(links) + .env(EnvVars::UV_PREVIEW_FEATURES, "tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + install_tool(&context, "basic-app", true); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{ "vulns": [] }, { "vulns": [] }] + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + Auditing `basic-app` + Found no known vulnerabilities and no adverse project statuses in 1 package + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 2 packages + "); +} + +#[tokio::test] +async fn tool_audit_vulnerability() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_vulnerable_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 1 (failure) + ----- stdout ----- + Tool `simple-launcher`: + + Vulnerabilities: + + simple-launcher 0.1.0 has 1 known vulnerability: + + - PYSEC-2026-0001: A test vulnerability in simple-launcher + + Fixed in: 0.2.0 + + Advisory information: https://example.com/advisory/PYSEC-2026-0001 + + + ----- stderr ----- + Auditing `simple-launcher` + Found 1 known vulnerability and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_ignore() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_vulnerable_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--ignore") + .arg("PYSEC-2026-0001") + .arg("--ignore") + .arg("CVE-DOES-NOT-EXIST") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + warning: Ignored vulnerability `CVE-DOES-NOT-EXIST` does not match any vulnerability in the selected tools + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); +} + +#[tokio::test] +async fn tool_audit_configured_ignore() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let config = context.temp_dir.child("uv.toml"); + install_tool(&context, "simple-launcher", true); + config.write_str(indoc! {r#" + [audit] + ignore = ["PYSEC-2026-0001"] + "#})?; + + let server = MockServer::start().await; + mount_vulnerable_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--config-file") + .arg(config.path()) + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stderr ----- + Auditing `simple-launcher` + Found no known vulnerabilities and no adverse project statuses in 1 package + "); + + Ok(()) +} + +#[tokio::test] +async fn tool_audit_json() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("json") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks,json-output") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "tools": [ + { + "name": "simple-launcher", + "summary": { + "audited_packages": 1, + "vulnerabilities": 0, + "adverse_statuses": 0 + }, + "vulnerabilities": [], + "adverse_statuses": [] + } + ] + } + "#); +} + +#[tokio::test] +async fn tool_audit_json_preview_warning() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("json") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "tools": [ + { + "name": "simple-launcher", + "summary": { + "audited_packages": 1, + "vulnerabilities": 0, + "adverse_statuses": 0 + }, + "vulnerabilities": [], + "adverse_statuses": [] + } + ] + } + + ----- stderr ----- + warning: The `--output-format json` option is experimental and the schema may change without warning. Pass `--preview-features json-output` to disable this warning. + "#); +} + +#[tokio::test] +async fn tool_audit_json_all_tools() { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + install_tool(&context, "basic-app", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("json") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks,json-output") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "tools": [ + { + "name": "basic-app", + "summary": { + "audited_packages": 1, + "vulnerabilities": 0, + "adverse_statuses": 0 + }, + "vulnerabilities": [], + "adverse_statuses": [] + }, + { + "name": "simple-launcher", + "summary": { + "audited_packages": 1, + "vulnerabilities": 0, + "adverse_statuses": 0 + }, + "vulnerabilities": [], + "adverse_statuses": [] + } + ] + } + "#); +} + +#[tokio::test] +async fn tool_audit_sarif() { + let context = uv_test::test_context!("3.12").with_filter((uv_version::version(), "[VERSION]")); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("sarif") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json", + "runs": [ + { + "automationDetails": { + "id": "uv/tool-audit/simple-launcher" + }, + "invocations": [ + { + "executionSuccessful": true + } + ], + "results": [], + "tool": { + "driver": { + "downloadUri": "https://github.com/astral-sh/uv", + "informationUri": "https://pypi.org/project/uv/", + "name": "uv", + "semanticVersion": "[VERSION]", + "version": "[VERSION]" + } + } + } + ], + "version": "2.1.0" + } + "#); +} + +#[test] +fn tool_audit_sarif_no_auditable_tools() { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("sarif") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json", + "runs": [], + "version": "2.1.0" + } + "#); + + install_tool(&context, "simple-launcher", false); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("--all") + .arg("--output-format") + .arg("sarif") + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @r#" + exit_code: 0 (success) + ----- stdout ----- + { + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json", + "runs": [], + "version": "2.1.0" + } + + ----- stderr ----- + warning: Skipping tool `simple-launcher` because it does not have a lockfile; reinstall it with `--preview-features tool-install-locks` to audit it + "#); +} + +#[tokio::test] +async fn tool_audit_sarif_all_tools() -> Result<()> { + let context = uv_test::test_context!("3.13"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + install_tool(&context, "basic-app", true); + + let server = MockServer::start().await; + mount_clean_service(&server).await; + + let output = context + .tool_audit() + .arg("--all") + .arg("--output-format") + .arg("sarif") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .output()?; + assert_eq!(output.status.code(), Some(0)); + + let report: Value = serde_json::from_slice(&output.stdout)?; + let runs = report["runs"].as_array().map(|runs| { + runs.iter() + .map(|run| { + json!({ + "automation_id": run["automationDetails"]["id"], + "results": run["results"], + }) + }) + .collect::>() + }); + assert_json_snapshot!(runs, @r#" + [ + { + "automation_id": "uv/tool-audit/basic-app", + "results": [] + }, + { + "automation_id": "uv/tool-audit/simple-launcher", + "results": [] + } + ] + "#); + + Ok(()) +} + +#[tokio::test] +async fn tool_audit_sarif_vulnerability_location() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + install_tool(&context, "simple-launcher", true); + + let server = MockServer::start().await; + mount_vulnerable_service(&server).await; + + let output = context + .tool_audit() + .arg("simple-launcher") + .arg("--output-format") + .arg("sarif") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .output()?; + assert_eq!(output.status.code(), Some(1)); + + let report: Value = serde_json::from_slice(&output.stdout)?; + assert_json_snapshot!(json!({ + "automation_id": report["runs"][0]["automationDetails"]["id"], + "artifact": report["runs"][0]["results"][0]["locations"][0]["physicalLocation"] + ["artifactLocation"]["uri"], + "rule": report["runs"][0]["results"][0]["ruleId"], + "runs": report["runs"].as_array().map(Vec::len), + }), @r#" + { + "artifact": "temp/tools/simple-launcher/uv.lock", + "automation_id": "uv/tool-audit/simple-launcher", + "rule": "PYSEC-2026-0001", + "runs": 1 + } + "#); + + Ok(()) +} + +#[tokio::test] +async fn tool_audit_persisted_index_and_project_status() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let server = MockServer::start().await; + let wheel_filename = "simple_launcher-0.1.0-py3-none-any.whl"; + let wheel = fs_err::read( + context + .workspace_root + .join("test/links") + .join(wheel_filename), + )?; + + let simple_index = json!({ + "meta": { "api-version": "1.1" }, + "name": "simple-launcher", + "project-status": { + "status": "archived", + "reason": "no-longer-maintained" + }, + "files": [{ + "filename": wheel_filename, + "url": format!("{}/files/{wheel_filename}", server.uri()), + "hashes": { + "sha256": "5327e0bb67cdb46800999de6dcf034bf0a5335702883494af0d8b7f6ca48cee4" + }, + "core-metadata": true, + "upload-time": "2024-03-24T00:00:00Z" + }] + }); + Mock::given(method("GET")) + .and(path("/simple/simple-launcher/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + simple_index.to_string(), + "application/vnd.pypi.simple.v1+json", + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/files/{wheel_filename}.metadata"))) + .respond_with(ResponseTemplate::new(200).set_body_string(indoc! {" + Metadata-Version: 2.1 + Name: simple-launcher + Version: 0.1.0 + Requires-Python: >=3.8 + "})) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/files/{wheel_filename}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(wheel)) + .mount(&server) + .await; + mount_clean_service(&server).await; + + context + .tool_install() + .arg("simple-launcher") + .arg("--index-url") + .arg(format!("{}/simple", server.uri())) + .env(EnvVars::UV_PREVIEW_FEATURES, "tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_audit() + .arg("simple-launcher") + .arg("--service-url") + .arg(server.uri()) + .env(EnvVars::UV_PREVIEW_FEATURES, "audit,tool-install-locks") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()), @" + exit_code: 0 (success) + ----- stdout ----- + Tool `simple-launcher`: + + Adverse statuses: + + - simple-launcher is archived: no-longer-maintained + + ----- stderr ----- + Auditing `simple-launcher` + Found no known vulnerabilities and 1 adverse project status in 1 package + "); + + Ok(()) +}