diff --git a/Cargo.lock b/Cargo.lock index 1579c443711e4..61c4a5b53e73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5751,6 +5751,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 9c51388aa294b..fe4dd2a6846d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -445,6 +445,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 b0e9b90f8e6cb..a706f28f32e06 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 c4a62b4f8f0b5..706690701036a 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", + ), + 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-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", + ), + 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, ), - 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, - ), - }, + ), ] "#); @@ -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", ), - 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", + summary: Some( + "cryptography Vulnerable to a Subgroup Attack Due to Missing Subgroup Validation for SECT Curves", ), - ], - published: Some( - 2026-02-10T21:27:06Z, - ), - modified: Some( - 2026-02-11T15:58:46.005582Z, - ), - } - "#); + 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, + ), + }, + ) + "###); } } diff --git a/crates/uv-audit/src/types.rs b/crates/uv-audit/src/types.rs index 28f58045bfb0b..5992deebadd8c 100644 --- a/crates/uv-audit/src/types.rs +++ b/crates/uv-audit/src/types.rs @@ -67,31 +67,83 @@ pub enum AdverseStatus { Deprecated, } -/// Represents a finding on a dependency. +/// A vulnerability within a dependency. #[derive(Debug)] -pub enum Finding { - /// A vulnerability within a dependency. - Vulnerability { - /// The dependency that is vulnerable. +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, - /// 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. + summary: Option, + description: Option, 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, - }, + ) -> 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 { + Vulnerability(Vulnerability), + ProjectStatus(ProjectStatus), } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 6d59f258d8062..3a4ba480c82e3 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 3a82093c0a0fc..ecd96ac0a6b3c 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 6ad15010aa848..9089fde367ae7 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,