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 <william@astral.sh>
This commit is contained in:
William Woodruff
2026-03-10 12:07:56 +08:00
committed by GitHub
parent bec06f62bb
commit f54ce6768d
8 changed files with 381 additions and 122 deletions
Generated
+1
View File
@@ -5741,6 +5741,7 @@ dependencies = [
"unicode-width 0.2.2",
"url",
"uuid",
"uv-audit",
"uv-auth",
"uv-bin-install",
"uv-build-backend",
-3
View File
@@ -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"]
+1
View File
@@ -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"
+39 -21
View File
@@ -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<Vec<Finding>, Error> {
pub async fn query(
&self,
dependency: &types::Dependency,
) -> Result<Vec<types::Finding>, Error> {
let mut all_vulnerabilities = Vec::new();
let mut page_token: Option<String> = 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,6 +406,7 @@ mod tests {
insta::assert_debug_snapshot!(findings, @r#"
[
Vulnerability(
Vulnerability {
dependency: Dependency {
name: PackageName(
@@ -411,7 +417,8 @@ mod tests {
id: VulnerabilityID(
"VULN-1",
),
description: "VULN-1",
summary: None,
description: None,
fix_versions: [],
aliases: [],
published: Some(
@@ -421,6 +428,8 @@ mod tests {
2026-01-01T00:00:00Z,
),
},
),
Vulnerability(
Vulnerability {
dependency: Dependency {
name: PackageName(
@@ -431,7 +440,8 @@ mod tests {
id: VulnerabilityID(
"VULN-2",
),
description: "VULN-2",
summary: None,
description: None,
fix_versions: [],
aliases: [],
published: Some(
@@ -441,6 +451,7 @@ mod tests {
2026-01-02T00:00:00Z,
),
},
),
]
"#);
@@ -471,12 +482,13 @@ 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#"
insta::assert_debug_snapshot!(finding, @r###"
Vulnerability(
Vulnerability {
dependency: Dependency {
name: PackageName(
@@ -487,7 +499,12 @@ mod tests {
id: VulnerabilityID(
"GHSA-r6ph-v2qm-q3c2",
),
description: "cryptography Vulnerable to a Subgroup Attack Due to Missing Subgroup Validation for SECT Curves",
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",
],
@@ -502,7 +519,8 @@ mod tests {
modified: Some(
2026-02-11T15:58:46.005582Z,
),
}
"#);
},
)
"###);
}
}
+76 -24
View File
@@ -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<String>,
/// A full-length description of the vulnerability, if available.
pub description: Option<String>,
/// Zero or more versions that fix the vulnerability.
pub fix_versions: Vec<Version>,
/// Zero or more aliases for this vulnerability in other databases.
pub aliases: Vec<VulnerabilityID>,
/// The timestamp when this vulnerability was published, if available.
pub published: Option<Timestamp>,
/// The timestamp when this vulnerability was last modified, if available.
pub modified: Option<Timestamp>,
}
impl Vulnerability {
pub fn new(
dependency: Dependency,
id: VulnerabilityID,
summary: Option<String>,
description: Option<String>,
fix_versions: Vec<Version>,
aliases: Vec<VulnerabilityID>,
published: Option<Timestamp>,
modified: Option<Timestamp>,
) -> 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<String>,
}
/// 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<Version>,
/// Zero or more aliases for this vulnerability in other databases.
aliases: Vec<VulnerabilityID>,
/// The timestamp when this vulnerability was published, if available.
published: Option<Timestamp>,
/// The timestamp when this vulnerability was last modified, if available.
modified: Option<Timestamp>,
},
/// 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),
}
+1
View File
@@ -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 }
+171 -16
View File
@@ -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::{
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,
default_dependency_groups,
lock::{LockMode, LockOperation},
lock_target::LockTarget,
},
},
printer::Printer,
settings::{FrozenSource, LockCheck, ResolverSettings},
};
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?
// Perform the audit.
warn_user!(
"Would have audited {n} dependencies.",
n = lock.packages().len()
// 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.
// 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<Finding>,
}
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(())
}
}
+34
View File
@@ -751,6 +751,40 @@ impl LatestVersionReporter {
}
}
#[derive(Debug)]
pub(crate) struct AuditReporter {
progress: ProgressBar,
}
impl From<Printer> 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,