Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions crates/uv-cache-info/src/git_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use tracing::warn;
use uv_fs::find_git_repository_root;
use walkdir::WalkDir;

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -111,11 +112,9 @@ struct GitRepository {
impl GitRepository {
/// Find the Git repository for a path, searching parent directories if necessary.
fn find(path: &Path) -> Result<Self, GitInfoError> {
let dot_git_path = path
.ancestors()
.map(|ancestor| ancestor.join(".git"))
.find(|dot_git_path| dot_git_path.exists())
let repository_root = find_git_repository_root(path)
.ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?;
let dot_git_path = repository_root.join(".git");
let git_dir = read_git_dir(&dot_git_path)
.ok_or_else(|| GitInfoError::MissingGitDir(path.to_path_buf()))?;
let common_dir = read_common_dir(&git_dir)?;
Expand Down
2 changes: 2 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub enum AuditOutputFormat {
Text,
/// Display the result in JSON format.
Json,
/// Display the result in SARIF format.
Sarif,
}

#[derive(Debug, Default, Clone, clap::ValueEnum)]
Expand Down
32 changes: 32 additions & 0 deletions crates/uv-fs/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,16 @@ pub fn relative_to(
Ok(up.join(stripped))
}

/// Find the root of the nearest Git repository containing `path`.
///
/// A `.git` directory or file is treated as a repository marker to support both regular
/// repositories and linked worktrees.
pub fn find_git_repository_root(path: &Path) -> Option<&Path> {
// TODO: Consider supporting GIT_CEILING_DIRECTORIES here.
path.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
}

/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
/// the absolute path. Falls back to absolute if relativization fails.
pub fn try_relative_to_if(
Expand Down Expand Up @@ -610,6 +620,28 @@ impl AsRef<Path> for PortablePathBuf {
mod tests {
use super::*;

#[test]
fn test_find_git_repository_root() -> std::io::Result<()> {
let temp_dir = tempfile::tempdir()?;

let repository = temp_dir.path().join("repository");
let nested = repository.join("packages/project");
fs_err::create_dir_all(repository.join(".git"))?;
fs_err::create_dir_all(&nested)?;
assert_eq!(
find_git_repository_root(&nested),
Some(repository.as_path())
);

let worktree = temp_dir.path().join("worktree");
let nested = worktree.join("packages/project");
fs_err::create_dir_all(&nested)?;
fs_err::write(worktree.join(".git"), "gitdir: ../repository/.git")?;
assert_eq!(find_git_repository_root(&nested), Some(worktree.as_path()));

Ok(())
}

#[test]
fn test_normalize_url() {
if cfg!(windows) {
Expand Down
192 changes: 42 additions & 150 deletions crates/uv/src/commands/project/audit.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use itertools::Itertools as _;
use owo_colors::OwoColorize;
use serde::Serialize;
use std::fmt::Write as _;
use std::path::Path;

Expand Down Expand Up @@ -30,6 +29,7 @@ use uv_cli::AuditOutputFormat;
use uv_client::{BaseClientBuilder, CachedClient, RegistryClientBuilder};
use uv_configuration::{Concurrency, DependencyGroups, ExtrasSpecification, 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::{PythonDownloads, PythonPreference, PythonVersion};
Expand All @@ -38,6 +38,9 @@ use uv_settings::PythonInstallMirrors;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache};

mod json;
mod sarif;

pub(crate) async fn audit(
project_dir: &Path,
extras: ExtrasSpecification,
Expand Down Expand Up @@ -312,6 +315,32 @@ pub(crate) async fn audit(
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('\\', "/")
},
};
display.render()
}
Expand All @@ -321,13 +350,15 @@ struct AuditResults {
n_packages: usize,
output_format: AuditOutputFormat,
findings: Vec<Finding>,
artifact_uri: String,
}

impl AuditResults {
fn render(&self) -> Result<ExitStatus> {
match self.output_format {
AuditOutputFormat::Text => self.render_text(),
AuditOutputFormat::Json => self.render_json(),
AuditOutputFormat::Sarif => self.render_sarif(),
}
}

Expand Down Expand Up @@ -491,7 +522,7 @@ impl AuditResults {

fn render_json(&self) -> Result<ExitStatus> {
let (vulnerabilities, statuses) = self.split_findings();
let report = JsonReport::from_findings(self.n_packages, &vulnerabilities, &statuses);
let report = json::Report::from_findings(self.n_packages, &vulnerabilities, &statuses);

writeln!(
self.printer.stdout_important(),
Expand All @@ -501,156 +532,17 @@ impl AuditResults {

Ok(self.exit_status())
}
}

#[derive(Debug, Serialize)]
struct JsonReport {
schema: JsonSchema,
summary: JsonSummary,
vulnerabilities: Vec<JsonVulnerability>,
adverse_statuses: Vec<JsonAdverseStatus>,
}

impl JsonReport {
fn from_findings(
n_packages: usize,
vulnerabilities: &[&Vulnerability],
statuses: &[&ProjectStatus],
) -> Self {
let mut vulnerabilities = vulnerabilities
.iter()
.copied()
.map(JsonVulnerability::from)
.collect::<Vec<_>>();
vulnerabilities.sort_by(|first, second| {
first
.dependency
.name
.cmp(&second.dependency.name)
.then_with(|| first.dependency.version.cmp(&second.dependency.version))
.then_with(|| first.display_id.cmp(&second.display_id))
});

let mut adverse_statuses = statuses
.iter()
.copied()
.map(JsonAdverseStatus::from)
.collect::<Vec<_>>();
adverse_statuses.sort_by(|first, second| {
first
.name
.cmp(&second.name)
.then_with(|| first.status.cmp(&second.status))
});

Self {
schema: JsonSchema::default(),
summary: JsonSummary {
audited_packages: n_packages,
vulnerabilities: vulnerabilities.len(),
adverse_statuses: adverse_statuses.len(),
},
vulnerabilities,
adverse_statuses,
}
}
}

#[derive(Debug, Serialize, Default)]
struct JsonSchema {
version: JsonSchemaVersion,
}

#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "snake_case")]
enum JsonSchemaVersion {
#[default]
Preview,
}

#[derive(Debug, Serialize)]
struct JsonSummary {
audited_packages: usize,
vulnerabilities: usize,
adverse_statuses: usize,
}

#[derive(Debug, Serialize)]
struct JsonDependency {
name: String,
version: String,
}

impl From<&Dependency> for JsonDependency {
fn from(dependency: &Dependency) -> Self {
Self {
name: dependency.name().to_string(),
version: dependency.version().to_string(),
}
}
}

#[derive(Debug, Serialize)]
struct JsonVulnerability {
dependency: JsonDependency,
id: String,
display_id: String,
aliases: Vec<String>,
summary: Option<String>,
description: Option<String>,
link: Option<String>,
fix_versions: Vec<String>,
published: Option<String>,
modified: Option<String>,
}

impl From<&Vulnerability> for JsonVulnerability {
fn from(vulnerability: &Vulnerability) -> Self {
Self {
dependency: JsonDependency::from(&vulnerability.dependency),
id: vulnerability.id.as_str().to_string(),
display_id: vulnerability.best_id().as_str().to_string(),
aliases: vulnerability
.aliases
.iter()
.map(|id| id.as_str().to_string())
.collect(),
summary: vulnerability.summary.clone(),
description: vulnerability.description.clone(),
link: vulnerability
.link
.as_ref()
.map(|link| link.as_str().to_string()),
fix_versions: vulnerability
.fix_versions
.iter()
.map(std::string::ToString::to_string)
.collect(),
published: vulnerability
.published
.as_ref()
.map(std::string::ToString::to_string),
modified: vulnerability
.modified
.as_ref()
.map(std::string::ToString::to_string),
}
}
}
fn render_sarif(&self) -> Result<ExitStatus> {
let (vulnerabilities, statuses) = self.split_findings();
let report = sarif::Report::from_findings(&vulnerabilities, &statuses, &self.artifact_uri);

#[derive(Debug, Serialize)]
struct JsonAdverseStatus {
name: String,
status: String,
reason: Option<String>,
}
writeln!(
self.printer.stdout_important(),
"{}",
serde_json::to_string_pretty(&report)?
)?;

impl From<&ProjectStatus> for JsonAdverseStatus {
fn from(status: &ProjectStatus) -> Self {
Self {
name: status.name.to_string(),
status: status.status.to_string(),
reason: status.reason.clone(),
}
Ok(self.exit_status())
}
}
Loading
Loading