diff --git a/crates/uv-cache-info/src/git_info.rs b/crates/uv-cache-info/src/git_info.rs index 10bbafdda05..40ceeca7b03 100644 --- a/crates/uv-cache-info/src/git_info.rs +++ b/crates/uv-cache-info/src/git_info.rs @@ -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)] @@ -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 { - 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)?; diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 8bc087b0387..354fcb2ed0c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -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)] diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 8e6eded8f94..d76810850a9 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -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( @@ -610,6 +620,28 @@ impl AsRef 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) { diff --git a/crates/uv/src/commands/project/audit.rs b/crates/uv/src/commands/project/audit.rs index 9a1f57a2317..e37d3b5e52d 100644 --- a/crates/uv/src/commands/project/audit.rs +++ b/crates/uv/src/commands/project/audit.rs @@ -1,6 +1,5 @@ use itertools::Itertools as _; use owo_colors::OwoColorize; -use serde::Serialize; use std::fmt::Write as _; use std::path::Path; @@ -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}; @@ -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, @@ -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() } @@ -321,6 +350,7 @@ struct AuditResults { n_packages: usize, output_format: AuditOutputFormat, findings: Vec, + artifact_uri: String, } impl AuditResults { @@ -328,6 +358,7 @@ impl AuditResults { match self.output_format { AuditOutputFormat::Text => self.render_text(), AuditOutputFormat::Json => self.render_json(), + AuditOutputFormat::Sarif => self.render_sarif(), } } @@ -491,7 +522,7 @@ impl AuditResults { fn render_json(&self) -> Result { 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(), @@ -501,156 +532,17 @@ impl AuditResults { Ok(self.exit_status()) } -} - -#[derive(Debug, Serialize)] -struct JsonReport { - schema: JsonSchema, - summary: JsonSummary, - vulnerabilities: Vec, - adverse_statuses: Vec, -} - -impl JsonReport { - fn from_findings( - n_packages: usize, - vulnerabilities: &[&Vulnerability], - statuses: &[&ProjectStatus], - ) -> Self { - let mut vulnerabilities = vulnerabilities - .iter() - .copied() - .map(JsonVulnerability::from) - .collect::>(); - 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::>(); - 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, - summary: Option, - description: Option, - link: Option, - fix_versions: Vec, - published: Option, - modified: Option, -} -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 { + 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, -} + 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()) } } diff --git a/crates/uv/src/commands/project/audit/json.rs b/crates/uv/src/commands/project/audit/json.rs new file mode 100644 index 00000000000..d4424b7f469 --- /dev/null +++ b/crates/uv/src/commands/project/audit/json.rs @@ -0,0 +1,155 @@ +//! JSON layout models for `uv audit`. + +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub(crate) struct Report { + schema: Schema, + summary: Summary, + vulnerabilities: Vec, + adverse_statuses: Vec, +} + +impl Report { + pub(crate) fn from_findings( + n_packages: usize, + vulnerabilities: &[&uv_audit::Vulnerability], + statuses: &[&uv_audit::ProjectStatus], + ) -> Self { + let mut vulnerabilities = vulnerabilities + .iter() + .copied() + .map(Vulnerability::from) + .collect::>(); + 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(AdverseStatus::from) + .collect::>(); + adverse_statuses.sort_by(|first, second| { + first + .name + .cmp(&second.name) + .then_with(|| first.status.cmp(&second.status)) + }); + + Self { + schema: Schema::default(), + summary: Summary { + audited_packages: n_packages, + vulnerabilities: vulnerabilities.len(), + adverse_statuses: adverse_statuses.len(), + }, + vulnerabilities, + adverse_statuses, + } + } +} + +#[derive(Debug, Serialize, Default)] +struct Schema { + version: SchemaVersion, +} + +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "snake_case")] +enum SchemaVersion { + #[default] + Preview, +} + +#[derive(Debug, Serialize)] +struct Summary { + audited_packages: usize, + vulnerabilities: usize, + adverse_statuses: usize, +} + +#[derive(Debug, Serialize)] +struct Dependency { + name: String, + version: String, +} + +impl From<&uv_audit::Dependency> for Dependency { + fn from(dependency: &uv_audit::Dependency) -> Self { + Self { + name: dependency.name().to_string(), + version: dependency.version().to_string(), + } + } +} + +#[derive(Debug, Serialize)] +struct Vulnerability { + dependency: Dependency, + id: String, + display_id: String, + aliases: Vec, + summary: Option, + description: Option, + link: Option, + fix_versions: Vec, + published: Option, + modified: Option, +} + +impl From<&uv_audit::Vulnerability> for Vulnerability { + fn from(vulnerability: &uv_audit::Vulnerability) -> Self { + Self { + dependency: Dependency::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), + } + } +} + +#[derive(Debug, Serialize)] +struct AdverseStatus { + name: String, + status: String, + reason: Option, +} + +impl From<&uv_audit::ProjectStatus> for AdverseStatus { + fn from(status: &uv_audit::ProjectStatus) -> Self { + Self { + name: status.name.to_string(), + status: status.status.to_string(), + reason: status.reason.clone(), + } + } +} diff --git a/crates/uv/src/commands/project/audit/sarif.rs b/crates/uv/src/commands/project/audit/sarif.rs new file mode 100644 index 00000000000..398c388b0e7 --- /dev/null +++ b/crates/uv/src/commands/project/audit/sarif.rs @@ -0,0 +1,446 @@ +//! SARIF layout models for `uv audit`. +//! +//! These models are adapted from the MIT-licensed `zizmor-sarif` crate, +//! copyright 2024 William Woodruff. Only the subset of SARIF 2.1.0 that +//! `uv audit` emits is modeled here. + +use std::collections::BTreeMap; + +use serde::Serialize; +use serde_json::Value; +use uv_audit::{AdverseStatus, ProjectStatus, Vulnerability}; + +/// Top-level SARIF log object (SARIF §3.13). +#[derive(Debug, Serialize)] +pub(crate) struct Report { + #[serde(rename = "$schema")] + schema: String, + runs: Vec, + version: String, +} + +impl Report { + pub(crate) fn from_findings( + vulnerabilities: &[&Vulnerability], + statuses: &[&ProjectStatus], + artifact_uri: &str, + ) -> Self { + let mut vulnerabilities = vulnerabilities.to_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.best_id().as_str().cmp(second.best_id().as_str())) + }); + + let mut statuses = statuses.to_vec(); + statuses.sort_by(|first, second| { + first + .name + .cmp(&second.name) + .then_with(|| first.status.to_string().cmp(&second.status.to_string())) + }); + + let mut rules = BTreeMap::new(); + let mut results = Vec::with_capacity(vulnerabilities.len() + statuses.len()); + + for vulnerability in vulnerabilities { + let rule = ReportingDescriptor::from_vulnerability(vulnerability); + let rule_id = rule.id.clone(); + rules.entry(rule_id.clone()).or_insert(rule); + results.push(Result::from_vulnerability( + vulnerability, + rule_id, + artifact_uri, + )); + } + + for status in statuses { + let rule = ReportingDescriptor::from_status(status); + let rule_id = rule.id.clone(); + rules.entry(rule_id.clone()).or_insert(rule); + results.push(Result::from_status(status, rule_id, artifact_uri)); + } + + Self { + schema: + "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json" + .to_string(), + runs: vec![Run { + invocations: vec![Invocation { + execution_successful: true, + }], + results, + tool: Tool { + driver: ToolComponent { + download_uri: Some(env!("CARGO_PKG_REPOSITORY").to_string()), + information_uri: Some(env!("CARGO_PKG_HOMEPAGE").to_string()), + name: env!("CARGO_PKG_NAME").to_string(), + rules: rules.into_values().collect(), + semantic_version: Some(env!("CARGO_PKG_VERSION").to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }, + }, + }], + version: "2.1.0".to_string(), + } + } +} + +/// A single tool invocation's results (SARIF §3.14). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Run { + invocations: Vec, + results: Vec, + tool: Tool, +} + +/// Tool metadata wrapper (SARIF §3.18). +#[derive(Debug, Serialize)] +struct Tool { + driver: ToolComponent, +} + +/// Tool driver metadata (SARIF §3.19). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ToolComponent { + #[serde(skip_serializing_if = "Option::is_none")] + download_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + information_uri: Option, + name: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + rules: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + semantic_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, +} + +/// Invocation describing the tool execution (SARIF §3.20). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Invocation { + execution_successful: bool, +} + +/// A reporting descriptor, i.e. a rule definition (SARIF §3.49). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReportingDescriptor { + #[serde(skip_serializing_if = "Option::is_none")] + help: Option, + #[serde(skip_serializing_if = "Option::is_none")] + help_uri: Option, + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + properties: Option, +} + +impl ReportingDescriptor { + fn from_vulnerability(vulnerability: &Vulnerability) -> Self { + let id = vulnerability.id.as_str().to_string(); + let name = vulnerability.best_id().as_str().to_string(); + let help = vulnerability + .description + .as_ref() + .or(vulnerability.summary.as_ref()) + .map(|description| MultiformatMessageString { + markdown: None, + text: description.clone(), + }); + + Self { + help, + help_uri: vulnerability + .link + .as_ref() + .map(|link| link.as_str().to_string()), + name: Some(name), + id, + properties: Some(PropertyBag { + tags: vec!["security".to_string(), "vulnerability".to_string()], + additional_properties: BTreeMap::new(), + }), + } + } + + fn from_status(status: &ProjectStatus) -> Self { + let (name, description) = match status.status { + AdverseStatus::Archived => ( + "archived", + "The project is archived and is no longer maintained.", + ), + AdverseStatus::Deprecated => ( + "deprecated", + "The project is deprecated and may have been superseded by another project.", + ), + AdverseStatus::Quarantined => ( + "quarantined", + "The project is quarantined and is considered unsafe for use.", + ), + }; + + Self { + help: Some(MultiformatMessageString { + markdown: None, + text: description.to_string(), + }), + help_uri: Some(format!( + "https://packaging.python.org/en/latest/specifications/project-status-markers/#{name}" + )), + id: format!("uv/project-status/{name}"), + name: Some(name.to_string()), + properties: Some(PropertyBag { + tags: vec!["package".to_string(), "project-status".to_string()], + additional_properties: BTreeMap::new(), + }), + } + } +} + +/// Plain-text and Markdown message (SARIF §3.12). +#[derive(Debug, Serialize)] +struct MultiformatMessageString { + #[serde(skip_serializing_if = "Option::is_none")] + markdown: Option, + text: String, +} + +/// Property bag (SARIF §3.8). +#[derive(Debug, Serialize)] +struct PropertyBag { + #[serde(skip_serializing_if = "Vec::is_empty")] + tags: Vec, + #[serde(flatten)] + additional_properties: BTreeMap, +} + +/// A single finding within a run (SARIF §3.27). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Result { + kind: ResultKind, + level: ResultLevel, + locations: Vec, + message: Message, + /// Tool-specific values that identify findings independently of their source location. + /// + /// GitHub code scanning only consumes `primaryLocationLineHash`. We intentionally do not use + /// that key for the semantic package identities below: all findings currently point at line 1, + /// so those values would not actually be location hashes. Other SARIF consumers can still use + /// these stable uv-specific keys. + partial_fingerprints: BTreeMap, + properties: PropertyBag, + rule_id: String, +} + +impl Result { + fn from_vulnerability( + vulnerability: &Vulnerability, + rule_id: String, + artifact_uri: &str, + ) -> Self { + let dependency = &vulnerability.dependency; + let name = dependency.name().to_string(); + let version = dependency.version().to_string(); + let display_id = vulnerability.best_id().as_str(); + let message = if let Some(summary) = &vulnerability.summary { + format!("{name} {version} is vulnerable to {display_id}: {summary}") + } else { + format!("{name} {version} is vulnerable to {display_id}") + }; + + let mut partial_fingerprints = BTreeMap::new(); + partial_fingerprints.insert( + "uv/vulnerability".to_string(), + format!("{}:{name}:{version}", vulnerability.id.as_str()), + ); + + let mut additional_properties = BTreeMap::new(); + additional_properties.insert( + "uv/aliases".to_string(), + Value::Array( + vulnerability + .aliases + .iter() + .map(|alias| Value::String(alias.as_str().to_string())) + .collect(), + ), + ); + additional_properties.insert( + "uv/displayId".to_string(), + Value::String(display_id.to_string()), + ); + additional_properties.insert( + "uv/fixVersions".to_string(), + Value::Array( + vulnerability + .fix_versions + .iter() + .map(|version| Value::String(version.to_string())) + .collect(), + ), + ); + additional_properties.insert( + "uv/id".to_string(), + Value::String(vulnerability.id.as_str().to_string()), + ); + additional_properties.insert("uv/package".to_string(), Value::String(name.clone())); + if let Some(modified) = &vulnerability.modified { + additional_properties.insert( + "uv/modified".to_string(), + Value::String(modified.to_string()), + ); + } + if let Some(published) = &vulnerability.published { + additional_properties.insert( + "uv/published".to_string(), + Value::String(published.to_string()), + ); + } + additional_properties.insert("uv/version".to_string(), Value::String(version.clone())); + + Self { + kind: ResultKind::Fail, + level: ResultLevel::Error, + locations: vec![Location::package(&name, Some(&version), artifact_uri)], + message: Message { text: message }, + partial_fingerprints, + properties: PropertyBag { + tags: Vec::new(), + additional_properties, + }, + rule_id, + } + } + + fn from_status(status: &ProjectStatus, rule_id: String, artifact_uri: &str) -> Self { + let name = status.name.to_string(); + let status_name = status.status.to_string(); + let message = if let Some(reason) = &status.reason { + format!("{name} is {status_name}: {reason}") + } else { + format!("{name} is {status_name}") + }; + + let mut partial_fingerprints = BTreeMap::new(); + partial_fingerprints.insert( + "uv/project-status".to_string(), + format!("{name}:{status_name}"), + ); + + let mut additional_properties = BTreeMap::new(); + additional_properties.insert("uv/package".to_string(), Value::String(name.clone())); + additional_properties.insert("uv/status".to_string(), Value::String(status_name)); + if let Some(reason) = &status.reason { + additional_properties.insert("uv/reason".to_string(), Value::String(reason.clone())); + } + + Self { + kind: ResultKind::Fail, + level: ResultLevel::Warning, + locations: vec![Location::package(&name, None, artifact_uri)], + message: Message { text: message }, + partial_fingerprints, + properties: PropertyBag { + tags: Vec::new(), + additional_properties, + }, + rule_id, + } + } +} + +/// A human-readable message (SARIF §3.11). +#[derive(Debug, Serialize)] +struct Message { + text: String, +} + +/// A location (SARIF §3.28). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Location { + logical_locations: Vec, + physical_location: PhysicalLocation, +} + +impl Location { + fn package(name: &str, version: Option<&str>, artifact_uri: &str) -> Self { + Self { + logical_locations: vec![LogicalLocation { + fully_qualified_name: Some( + version.map_or_else(|| name.to_string(), |version| format!("{name}@{version}")), + ), + kind: Some("package".to_string()), + name: Some(name.to_string()), + }], + // TODO: Point each finding at its `[[package]]` table instead of line 1. + // This requires us to have a spanning view of a lockfile similar to + // how `pyproject.toml` spans are emitted. We'd also need to figure out + // how to represent a discarded lockfile, e.g. from a audit of an + // unlocked script. + physical_location: PhysicalLocation { + artifact_location: ArtifactLocation { + uri: artifact_uri.to_string(), + }, + region: Region { start_line: 1 }, + }, + } + } +} + +/// A physical location (SARIF §3.29). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PhysicalLocation { + artifact_location: ArtifactLocation, + region: Region, +} + +/// Pointer to a single artifact (SARIF §3.4). +#[derive(Debug, Serialize)] +struct ArtifactLocation { + uri: String, +} + +/// A region within an artifact (SARIF §3.30). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Region { + start_line: u32, +} + +/// A logical location (SARIF §3.33). +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LogicalLocation { + #[serde(skip_serializing_if = "Option::is_none")] + fully_qualified_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +/// Classification of a result (SARIF §3.27.9). +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum ResultKind { + Fail, +} + +/// Severity of a result (SARIF §3.27.10). +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum ResultLevel { + Warning, + Error, +} diff --git a/crates/uv/tests/build/audit.rs b/crates/uv/tests/build/audit.rs index 0feebedd033..d3520e70c7d 100644 --- a/crates/uv/tests/build/audit.rs +++ b/crates/uv/tests/build/audit.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use indoc::{formatdoc, indoc}; @@ -7,8 +8,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use uv_test::uv_snapshot; -fn write_audit_json_project(context: &uv_test::TestContext, index_url: &str) { - let pyproject_toml = context.temp_dir.child("pyproject.toml"); +fn write_audit_output_project(project_dir: &impl PathChild, index_url: &str) { + let pyproject_toml = project_dir.child("pyproject.toml"); pyproject_toml .write_str(&formatdoc! {r#" [project] @@ -23,7 +24,7 @@ fn write_audit_json_project(context: &uv_test::TestContext, index_url: &str) { "#}) .unwrap(); - let lockfile = context.temp_dir.child("uv.lock"); + let lockfile = project_dir.child("uv.lock"); lockfile .write_str(&formatdoc! {r#" version = 1 @@ -107,7 +108,7 @@ async fn audit_no_vulnerabilities() { async fn audit_json_no_vulnerabilities() { let context = uv_test::test_context!("3.12"); let proxy = crate::pypi_proxy::start().await; - write_audit_json_project(&context, &proxy.url("/simple")); + write_audit_output_project(&context.temp_dir, &proxy.url("/simple")); let server = MockServer::start().await; @@ -153,7 +154,7 @@ async fn audit_json_no_vulnerabilities() { async fn audit_json_preview_warning() { let context = uv_test::test_context!("3.12"); let proxy = crate::pypi_proxy::start().await; - write_audit_json_project(&context, &proxy.url("/simple")); + write_audit_output_project(&context.temp_dir, &proxy.url("/simple")); let server = MockServer::start().await; @@ -2260,7 +2261,7 @@ async fn audit_vulnerability_and_project_status() { async fn audit_json_vulnerability_and_project_status() { let context = uv_test::test_context!("3.12"); let proxy = crate::pypi_proxy::start().await; - write_audit_json_project(&context, &proxy.url("/status/archived/simple")); + write_audit_output_project(&context.temp_dir, &proxy.url("/status/archived/simple")); let server = MockServer::start().await; Mock::given(method("POST")) @@ -2346,3 +2347,281 @@ async fn audit_json_vulnerability_and_project_status() { ----- stderr ----- "#); } + +/// SARIF output includes vulnerabilities and adverse project statuses in the +/// same audit report. +#[tokio::test] +async fn audit_sarif_vulnerability_and_project_status() -> Result<()> { + let context = uv_test::test_context!("3.12").with_filter((uv_version::version(), "[VERSION]")); + context.temp_dir.child(".git").create_dir_all()?; + let proxy = crate::pypi_proxy::start().await; + write_audit_output_project(&context.temp_dir, &proxy.url("/status/archived/simple")); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "OSV-2023-0001"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/OSV-2023-0001")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "OSV-2023-0001", + "aliases": ["PYSEC-2023-0001"], + "modified": "2026-01-01T00:00:00Z", + "published": "2025-12-01T00:00:00Z", + "summary": "A test vulnerability in iniconfig", + "details": "A longer description of the test vulnerability.", + "affected": [{ + "ranges": [{ + "type": "ECOSYSTEM", + "events": [ + {"introduced": "0"}, + {"fixed": "2.1.0"} + ] + }] + }], + "references": [{ + "type": "ADVISORY", + "url": "https://example.com/advisory/PYSEC-2023-0001" + }] + }))) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context + .audit() + .arg("--preview-features") + .arg("audit") + .arg("--output-format") + .arg("sarif") + .arg("--frozen") + .arg("--service-url") + .arg(server.uri()), @r#" + success: false + exit_code: 1 + ----- stdout ----- + { + "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json", + "runs": [ + { + "invocations": [ + { + "executionSuccessful": true + } + ], + "results": [ + { + "kind": "fail", + "level": "error", + "locations": [ + { + "logicalLocations": [ + { + "fullyQualifiedName": "iniconfig@2.0.0", + "kind": "package", + "name": "iniconfig" + } + ], + "physicalLocation": { + "artifactLocation": { + "uri": "uv.lock" + }, + "region": { + "startLine": 1 + } + } + } + ], + "message": { + "text": "iniconfig 2.0.0 is vulnerable to PYSEC-2023-0001: A test vulnerability in iniconfig" + }, + "partialFingerprints": { + "uv/vulnerability": "OSV-2023-0001:iniconfig:2.0.0" + }, + "properties": { + "uv/aliases": [ + "PYSEC-2023-0001" + ], + "uv/displayId": "PYSEC-2023-0001", + "uv/fixVersions": [ + "2.1.0" + ], + "uv/id": "OSV-2023-0001", + "uv/modified": "2026-01-01T00:00:00Z", + "uv/package": "iniconfig", + "uv/published": "2025-12-01T00:00:00Z", + "uv/version": "2.0.0" + }, + "ruleId": "OSV-2023-0001" + }, + { + "kind": "fail", + "level": "warning", + "locations": [ + { + "logicalLocations": [ + { + "fullyQualifiedName": "iniconfig", + "kind": "package", + "name": "iniconfig" + } + ], + "physicalLocation": { + "artifactLocation": { + "uri": "uv.lock" + }, + "region": { + "startLine": 1 + } + } + } + ], + "message": { + "text": "iniconfig is archived" + }, + "partialFingerprints": { + "uv/project-status": "iniconfig:archived" + }, + "properties": { + "uv/package": "iniconfig", + "uv/status": "archived" + }, + "ruleId": "uv/project-status/archived" + } + ], + "tool": { + "driver": { + "downloadUri": "https://github.com/astral-sh/uv", + "informationUri": "https://pypi.org/project/uv/", + "name": "uv", + "rules": [ + { + "help": { + "text": "A longer description of the test vulnerability." + }, + "helpUri": "https://example.com/advisory/PYSEC-2023-0001", + "id": "OSV-2023-0001", + "name": "PYSEC-2023-0001", + "properties": { + "tags": [ + "security", + "vulnerability" + ] + } + }, + { + "help": { + "text": "The project is archived and is no longer maintained." + }, + "helpUri": "https://packaging.python.org/en/latest/specifications/project-status-markers/#archived", + "id": "uv/project-status/archived", + "name": "archived", + "properties": { + "tags": [ + "package", + "project-status" + ] + } + } + ], + "semanticVersion": "[VERSION]", + "version": "[VERSION]" + } + } + } + ], + "version": "2.1.0" + } + + ----- stderr ----- + "#); + + Ok(()) +} + +/// SARIF artifact locations are relative to the repository root, whether the +/// project is selected with `--project` or uv is invoked from the project directory. +#[tokio::test] +async fn audit_sarif_project_artifact_uri() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context.temp_dir.child(".git").create_dir_all()?; + let proxy = crate::pypi_proxy::start().await; + let project_dir = context.temp_dir.child("packages/project"); + project_dir.create_dir_all()?; + write_audit_output_project(&project_dir, &proxy.url("/simple")); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/querybatch")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": [{"vulns": [{"id": "PYSEC-2023-0001"}]}] + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/v1/vulns/PYSEC-2023-0001")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "PYSEC-2023-0001", + "modified": "2026-01-01T00:00:00Z", + "affected": [{ + "ranges": [{ + "type": "ECOSYSTEM", + "events": [{"introduced": "0"}] + }] + }] + }))) + .mount(&server) + .await; + + let assert = context + .audit() + .arg("--preview-features") + .arg("audit") + .arg("--output-format") + .arg("sarif") + .arg("--frozen") + .arg("--project") + .arg(project_dir.path()) + .arg("--service-url") + .arg(server.uri()) + .assert() + .failure() + .code(1); + let project_report: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + + let assert = context + .audit() + .arg("--preview-features") + .arg("audit") + .arg("--output-format") + .arg("sarif") + .arg("--frozen") + .arg("--service-url") + .arg(server.uri()) + .current_dir(&project_dir) + .assert() + .failure() + .code(1); + let nested_report: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + + let artifact_uris = [ + project_report + .pointer("/runs/0/results/0/locations/0/physicalLocation/artifactLocation/uri"), + nested_report + .pointer("/runs/0/results/0/locations/0/physicalLocation/artifactLocation/uri"), + ]; + + insta::assert_json_snapshot!(artifact_uris, @r#" + [ + "packages/project/uv.lock", + "packages/project/uv.lock" + ] + "#); + + Ok(()) +}