From f54ce6768dc87cf748f36fe0ac01b6320d5b5c3f Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 10 Mar 2026 12:07:56 +0800 Subject: [PATCH] Output/report formatting for `uv audit` (#18193) ## Summary This adds some initial output/report formatting for `uv audit`. This is an initial blush, any feedback to align this with rendering/formatting idioms elsewhere would be greatly appreciated! Atop #18119. ## Test Plan None yet. --------- Signed-off-by: William Woodruff --- Cargo.lock | 1 + Cargo.toml | 3 - _typos.toml | 1 + crates/uv-audit/src/service/osv.rs | 174 ++++++++++++---------- crates/uv-audit/src/types.rs | 100 ++++++++++--- crates/uv/Cargo.toml | 1 + crates/uv/src/commands/project/audit.rs | 189 +++++++++++++++++++++--- crates/uv/src/commands/reporters.rs | 34 +++++ 8 files changed, 381 insertions(+), 122 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88e5d1aef2..f42ed220cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5741,6 +5741,7 @@ dependencies = [ "unicode-width 0.2.2", "url", "uuid", + "uv-audit", "uv-auth", "uv-bin-install", "uv-build-backend", diff --git a/Cargo.toml b/Cargo.toml index 188c27e4a0..df4b6e0597 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -446,6 +446,3 @@ codegen-units = 1 # The profile that 'cargo dist' will build with. [profile.dist] inherits = "release" - -[workspace.metadata.cargo-shear] -ignored = ["uv-audit"] diff --git a/_typos.toml b/_typos.toml index b0e9b90f8e..a706f28f32 100644 --- a/_typos.toml +++ b/_typos.toml @@ -26,3 +26,4 @@ CPY_VERSION_RE = "CPY_VERSION_RE" # CPython version regex [default.extend-words] certifi = "certifi" # Python package name Iz = "Iz" # appears in base64-encoded hashes +vulnerabilit = "vulnerabilit" # appears in pluralization renderings of "vulnerability"/"vulnerabilities" diff --git a/crates/uv-audit/src/service/osv.rs b/crates/uv-audit/src/service/osv.rs index c4a62b4f8f..7066907010 100644 --- a/crates/uv-audit/src/service/osv.rs +++ b/crates/uv-audit/src/service/osv.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use uv_pep440::Version; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; -use crate::types::{Dependency, Finding, VulnerabilityID}; +use crate::types; const API_BASE: &str = "https://api.osv.dev/"; @@ -161,7 +161,10 @@ impl Osv { } /// Query OSV for vulnerabilities affecting the given dependency. - pub async fn query(&self, dependency: &Dependency) -> Result, Error> { + pub async fn query( + &self, + dependency: &types::Dependency, + ) -> Result, Error> { let mut all_vulnerabilities = Vec::new(); let mut page_token: Option = None; @@ -216,7 +219,10 @@ impl Osv { } /// Convert an OSV Vulnerability record to a Finding. - fn vulnerability_to_finding(dependency: &Dependency, vuln: Vulnerability) -> Finding { + fn vulnerability_to_finding( + dependency: &types::Dependency, + vuln: Vulnerability, + ) -> types::Finding { // Extract fix versions from affected ranges let fix_versions = vuln .affected @@ -249,20 +255,19 @@ impl Osv { .aliases .unwrap_or_default() .into_iter() - .map(VulnerabilityID::new) + .map(types::VulnerabilityID::new) .collect(); - let description = vuln.summary.or(vuln.details).unwrap_or(vuln.id.clone()); - - Finding::Vulnerability { - dependency: dependency.clone(), - id: VulnerabilityID::new(vuln.id), - description, + types::Finding::Vulnerability(types::Vulnerability::new( + dependency.clone(), + types::VulnerabilityID::new(vuln.id), + vuln.summary, + vuln.details, fix_versions, aliases, - published: vuln.published, - modified: Some(vuln.modified), - } + vuln.published, + Some(vuln.modified), + )) } } @@ -401,46 +406,52 @@ mod tests { insta::assert_debug_snapshot!(findings, @r#" [ - Vulnerability { - dependency: Dependency { - name: PackageName( - "foobar", + Vulnerability( + Vulnerability { + dependency: Dependency { + name: PackageName( + "foobar", + ), + version: "1.2.3", + }, + id: VulnerabilityID( + "VULN-1", ), - version: "1.2.3", - }, - id: VulnerabilityID( - "VULN-1", - ), - description: "VULN-1", - fix_versions: [], - aliases: [], - published: Some( - 2026-01-01T00:00:00Z, - ), - modified: Some( - 2026-01-01T00:00:00Z, - ), - }, - Vulnerability { - dependency: Dependency { - name: PackageName( - "foobar", + summary: None, + description: None, + fix_versions: [], + aliases: [], + published: Some( + 2026-01-01T00:00:00Z, + ), + modified: Some( + 2026-01-01T00:00:00Z, ), - version: "1.2.3", }, - id: VulnerabilityID( - "VULN-2", - ), - description: "VULN-2", - fix_versions: [], - aliases: [], - published: Some( - 2026-01-02T00:00:00Z, - ), - modified: Some( - 2026-01-02T00:00:00Z, - ), - }, + ), + Vulnerability( + Vulnerability { + dependency: Dependency { + name: PackageName( + "foobar", + ), + version: "1.2.3", + }, + id: VulnerabilityID( + "VULN-2", + ), + summary: None, + description: None, + fix_versions: [], + aliases: [], + published: Some( + 2026-01-02T00:00:00Z, + ), + modified: Some( + 2026-01-02T00:00:00Z, + ), + }, + ), ] "#); @@ -471,38 +482,45 @@ mod tests { let finding = findings .iter() .find(|finding| match finding { - Finding::Vulnerability { id, .. } => id.as_str() == "GHSA-r6ph-v2qm-q3c2", - Finding::ProjectStatus { .. } => false, + Finding::Vulnerability(vuln) => vuln.id.as_str() == "GHSA-r6ph-v2qm-q3c2", + Finding::ProjectStatus(_) => false, }) .expect("Expected to find GHSA-r6ph-v2qm-q3c2 vulnerability"); - insta::assert_debug_snapshot!(finding, @r#" - Vulnerability { - dependency: Dependency { - name: PackageName( - "cryptography", + insta::assert_debug_snapshot!(finding, @r###" + Vulnerability( + Vulnerability { + dependency: Dependency { + name: PackageName( + "cryptography", + ), + version: "46.0.4", + }, + id: VulnerabilityID( + "GHSA-r6ph-v2qm-q3c2", + ), + summary: Some( + "cryptography Vulnerable to a Subgroup Attack Due to Missing Subgroup Validation for SECT Curves", + ), + description: Some( + "## Vulnerability Summary\n\nThe `public_key_from_numbers` (or `EllipticCurvePublicNumbers.public_key()`), `EllipticCurvePublicNumbers.public_key()`, `load_der_public_key()` and `load_pem_public_key()` functions do not verify that the point belongs to the expected prime-order subgroup of the curve.\n\nThis missing validation allows an attacker to provide a public key point `P` from a small-order subgroup. This can lead to security issues in various situations, such as the most commonly used signature verification (ECDSA) and shared key negotiation (ECDH). When the victim computes the shared secret as `S = [victim_private_key]P` via ECDH, this leaks information about `victim_private_key mod (small_subgroup_order)`. For curves with cofactor > 1, this reveals the least significant bits of the private key. When these weak public keys are used in ECDSA , it's easy to forge signatures on the small subgroup.\n\nOnly SECT curves are impacted by this.\n\n## Credit\n\nThis vulnerability was discovered by:\n- XlabAI Team of Tencent Xuanwu Lab\n- Atuin Automated Vulnerability Discovery Engine", + ), + fix_versions: [ + "46.0.5", + ], + aliases: [ + VulnerabilityID( + "CVE-2026-26007", + ), + ], + published: Some( + 2026-02-10T21:27:06Z, + ), + modified: Some( + 2026-02-11T15:58:46.005582Z, ), - version: "46.0.4", }, - id: VulnerabilityID( - "GHSA-r6ph-v2qm-q3c2", - ), - description: "cryptography Vulnerable to a Subgroup Attack Due to Missing Subgroup Validation for SECT Curves", - fix_versions: [ - "46.0.5", - ], - aliases: [ - VulnerabilityID( - "CVE-2026-26007", - ), - ], - published: Some( - 2026-02-10T21:27:06Z, - ), - modified: Some( - 2026-02-11T15:58:46.005582Z, - ), - } - "#); + ) + "###); } } diff --git a/crates/uv-audit/src/types.rs b/crates/uv-audit/src/types.rs index 28f58045bf..5992deebad 100644 --- a/crates/uv-audit/src/types.rs +++ b/crates/uv-audit/src/types.rs @@ -67,31 +67,83 @@ pub enum AdverseStatus { Deprecated, } +/// A vulnerability within a dependency. +#[derive(Debug)] +pub struct Vulnerability { + /// The dependency that is vulnerable. + pub dependency: Dependency, + /// The unique identifier for the vulnerability. + pub id: VulnerabilityID, + /// A short, human-readable summary of the vulnerability, if available. + pub summary: Option, + /// A full-length description of the vulnerability, if available. + pub description: Option, + /// Zero or more versions that fix the vulnerability. + pub fix_versions: Vec, + /// Zero or more aliases for this vulnerability in other databases. + pub aliases: Vec, + /// The timestamp when this vulnerability was published, if available. + pub published: Option, + /// The timestamp when this vulnerability was last modified, if available. + pub modified: Option, +} + +impl Vulnerability { + pub fn new( + dependency: Dependency, + id: VulnerabilityID, + summary: Option, + description: Option, + fix_versions: Vec, + aliases: Vec, + published: Option, + modified: Option, + ) -> Self { + // Vulnerability summaries often contain excess whitespace, as well as newlines. + // We normalize these out. + let summary = summary.map(|summary| summary.trim().replace('\n', "")); + + Self { + dependency, + id, + summary, + description, + fix_versions, + aliases, + published, + modified, + } + } + + /// Pick the subjectively "best" identifier for this vulnerability. + /// For our purposes we prefer PYSEC IDs, then GHSA, then CVE, then whatever + /// primary ID the vulnerability came with. + pub fn best_id(&self) -> &VulnerabilityID { + std::iter::once(&self.id) + .chain(self.aliases.iter()) + .find(|id| { + id.as_str().starts_with("PYSEC-") + || id.as_str().starts_with("GHSA-") + || id.as_str().starts_with("CVE-") + }) + .unwrap_or(&self.id) + } +} + +/// An adverse project status, such as an archived or deprecated project. +#[derive(Debug)] +pub struct ProjectStatus { + /// The dependency with the adverse status. + pub dependency: Dependency, + /// The adverse status of the project. + pub status: AdverseStatus, + /// An optional (index-supplied) reason for the adverse status. + pub reason: Option, +} + /// Represents a finding on a dependency. #[derive(Debug)] pub enum Finding { - /// A vulnerability within a dependency. - Vulnerability { - /// The dependency that is vulnerable. - dependency: Dependency, - /// The unique identifier for the vulnerability. - id: VulnerabilityID, - /// A short, human-readable description of the vulnerability. - description: String, - /// Zero or more versions that fix the vulnerability. - fix_versions: Vec, - /// Zero or more aliases for this vulnerability in other databases. - aliases: Vec, - /// The timestamp when this vulnerability was published, if available. - published: Option, - /// The timestamp when this vulnerability was last modified, if available. - modified: Option, - }, - /// An adverse project status, such as an archived or deprecated project. - ProjectStatus { - /// The dependency with the adverse status. - dependency: Dependency, - /// The adverse status of the project. - status: AdverseStatus, - }, + Vulnerability(Vulnerability), + ProjectStatus(ProjectStatus), } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index bbce3b2c4f..dbef2e07e6 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -15,6 +15,7 @@ default-run = "uv" workspace = true [dependencies] +uv-audit = { workspace = true } uv-auth = { workspace = true } uv-bin-install = { workspace = true } uv-build-backend = { workspace = true } diff --git a/crates/uv/src/commands/project/audit.rs b/crates/uv/src/commands/project/audit.rs index 3a82093c0a..ecd96ac0a6 100644 --- a/crates/uv/src/commands/project/audit.rs +++ b/crates/uv/src/commands/project/audit.rs @@ -1,20 +1,26 @@ +use itertools::Itertools as _; +use owo_colors::OwoColorize; +use std::fmt::Write as _; use std::path::Path; -use crate::{ - commands::{ - ExitStatus, diagnostics, - pip::{loggers::DefaultResolveLogger, resolution_markers}, - project::{ - ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState, - default_dependency_groups, - lock::{LockMode, LockOperation}, - lock_target::LockTarget, - }, - }, - printer::Printer, - settings::{FrozenSource, LockCheck, ResolverSettings}, +use crate::commands::ExitStatus; +use crate::commands::diagnostics; +use crate::commands::pip::loggers::DefaultResolveLogger; +use crate::commands::pip::resolution_markers; +use crate::commands::project::default_dependency_groups; +use crate::commands::project::lock::{LockMode, LockOperation}; +use crate::commands::project::lock_target::LockTarget; +use crate::commands::project::{ + ProjectError, ProjectInterpreter, ScriptInterpreter, UniversalState, }; +use crate::commands::reporters::AuditReporter; +use crate::printer::Printer; +use crate::settings::{FrozenSource, LockCheck, ResolverSettings}; + use anyhow::Result; +use tracing::trace; +use uv_audit::service::osv; +use uv_audit::types::{Dependency, Finding}; use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, DependencyGroups, ExtrasSpecification, TargetTriple}; @@ -177,11 +183,160 @@ pub(crate) async fn audit( // TODO: validate the sets of requested extras/groups against the lockfile? + // Build the list of auditable packages, skipping workspace members. Workspace members are + // local by definition and have no meaningful external package identity to look up in a vuln + // service. We also skip packages without a version, since we can't query for them. + // + // This mirrors the logic in `TreeDisplay::new`: for single-member workspaces, `lock.members()` + // is empty and the root package (source at path "") is the implicit member. + let workspace_root_name = lock.root().map(uv_resolver::Package::name); + let auditable: Vec<_> = lock + .packages() + .iter() + .filter(|p| { + if lock.members().is_empty() { + // Single-member workspace: skip the implicit root. + workspace_root_name != Some(p.name()) + } else { + !lock.members().contains(p.name()) + } + }) + .filter_map(|p| { + let Some(version) = p.version() else { + trace!( + "Skipping audit for {} because it has no version information", + p.name() + ); + return None; + }; + Some((p.name(), version)) + }) + .collect(); + // Perform the audit. - warn_user!( - "Would have audited {n} dependencies.", - n = lock.packages().len() - ); + // TODO: Use `client_builder` to produce an HTTP client through our normal process here. + let service = osv::Osv::default(); + trace!("Auditing {n} dependencies against OSV", n = auditable.len()); + + let reporter = AuditReporter::from(printer).with_length(auditable.len() as u64); + + // TODO: Replace this loop with bulk auditing. + let mut all_findings = vec![]; + for (name, version) in &auditable { + reporter.on_audit_package(name, version); + let dependency = Dependency::new((*name).clone(), (*version).clone()); + all_findings.extend(service.query(&dependency).await?); + } + + reporter.on_audit_complete(); + + let display = AuditResults { + printer, + n_packages: auditable.len(), + findings: all_findings, + }; + display.render()?; Ok(ExitStatus::Success) } + +struct AuditResults { + printer: Printer, + n_packages: usize, + findings: Vec, +} + +impl AuditResults { + fn render(&self) -> Result<()> { + let (vulns, statuses): (Vec<_>, Vec<_>) = + self.findings.iter().partition_map(|finding| match finding { + Finding::Vulnerability(vuln) => itertools::Either::Left(vuln), + Finding::ProjectStatus(status) => itertools::Either::Right(status), + }); + + let vuln_banner = if !vulns.is_empty() { + let s = if vulns.len() == 1 { "y" } else { "ies" }; + format!("{} known vulnerabilit{}", vulns.len(), s) + .yellow() + .to_string() + } else { + "no known vulnerabilities".bold().to_string() + }; + + let status_banner = if !statuses.is_empty() { + let s = if statuses.len() == 1 { "" } else { "es" }; + format!( + "{} adverse project status{}", + statuses.len().to_string().yellow(), + s + ) + } else { + "no adverse project statuses".bold().to_string() + }; + + writeln!( + self.printer.stderr(), + "Found {vuln_banner} and {status_banner} in {packages}", + packages = format!("{npackages} packages", npackages = self.n_packages).bold() + )?; + + if !vulns.is_empty() { + writeln!(self.printer.stdout_important(), "\nVulnerabilities:\n")?; + + // Group vulnerabilities by (dependency name, version). + let groups = vulns + .into_iter() + .chunk_by(|vuln| (vuln.dependency.name(), vuln.dependency.version())); + + for (dependency, vulns) in &groups { + let vulns: Vec<_> = vulns.collect(); + let (name, version) = dependency; + + writeln!( + self.printer.stdout_important(), + "{name_version} has {n} known vulnerabilit{ies}:\n", + name_version = format!("{name} {version}").bold(), + n = vulns.len(), + ies = if vulns.len() == 1 { "y" } else { "ies" }, + )?; + + for vuln in vulns { + writeln!( + self.printer.stdout_important(), + "- {id}: {description}", + id = vuln.best_id().as_str().bold(), + description = vuln.summary.as_deref().unwrap_or("No summary provided"), + )?; + + if vuln.fix_versions.is_empty() { + writeln!( + self.printer.stdout_important(), + "\n No fix versions available\n" + )?; + } else { + writeln!( + self.printer.stdout_important(), + "\n Fixed in: {}\n", + vuln.fix_versions + .iter() + .map(std::string::ToString::to_string) + .join(", ") + .blue() + )?; + } + } + + writeln!(self.printer.stdout_important())?; + } + } + + if !statuses.is_empty() { + writeln!(self.printer.stdout_important(), "\nAdverse statuses:\n")?; + + // NOTE: Nothing here yet, since we don't actually produce + // any adverse project statuses at the moment. + } + + Ok(()) + } +} diff --git a/crates/uv/src/commands/reporters.rs b/crates/uv/src/commands/reporters.rs index 6ad15010aa..9089fde367 100644 --- a/crates/uv/src/commands/reporters.rs +++ b/crates/uv/src/commands/reporters.rs @@ -751,6 +751,40 @@ impl LatestVersionReporter { } } +#[derive(Debug)] +pub(crate) struct AuditReporter { + progress: ProgressBar, +} + +impl From for AuditReporter { + fn from(printer: Printer) -> Self { + let progress = ProgressBar::with_draw_target(None, printer.target()); + progress.set_style( + ProgressStyle::with_template("{bar:20} [{pos}/{len}] {wide_msg:.dim}").unwrap(), + ); + progress.set_message("Auditing dependencies..."); + Self { progress } + } +} + +impl AuditReporter { + #[must_use] + pub(crate) fn with_length(self, length: u64) -> Self { + self.progress.set_length(length); + self + } + + pub(crate) fn on_audit_package(&self, name: &PackageName, version: &Version) { + self.progress.set_message(format!("{name} {version}")); + self.progress.inc(1); + } + + pub(crate) fn on_audit_complete(&self) { + self.progress.set_message(""); + self.progress.finish_and_clear(); + } +} + #[derive(Debug)] pub(crate) struct CleaningDirectoryReporter { bar: ProgressBar,