Support SARIF as a uv audit output (#19872)

Signed-off-by: William Woodruff <william@yossarian.net>
This commit is contained in:
William Woodruff
2026-06-17 10:54:06 -04:00
committed by GitHub
parent 0370ed4913
commit 5fbf9c83da
7 changed files with 969 additions and 164 deletions
+3 -4
View File
@@ -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<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)?;
+2
View File
@@ -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)]
+32
View File
@@ -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<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) {
+46 -154
View File
@@ -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<Finding>,
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,20 @@ 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(),
"{}",
serde_json::to_string_pretty(&report)?
)?;
Ok(self.exit_status())
}
fn render_sarif(&self) -> Result<ExitStatus> {
let (vulnerabilities, statuses) = self.split_findings();
let report = sarif::Report::from_findings(&vulnerabilities, &statuses, &self.artifact_uri);
writeln!(
self.printer.stdout_important(),
@@ -502,155 +546,3 @@ 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),
}
}
}
#[derive(Debug, Serialize)]
struct JsonAdverseStatus {
name: String,
status: String,
reason: Option<String>,
}
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(),
}
}
}
@@ -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<Vulnerability>,
adverse_statuses: Vec<AdverseStatus>,
}
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::<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(AdverseStatus::from)
.collect::<Vec<_>>();
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<String>,
summary: Option<String>,
description: Option<String>,
link: Option<String>,
fix_versions: Vec<String>,
published: Option<String>,
modified: Option<String>,
}
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<String>,
}
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(),
}
}
}
@@ -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<Run>,
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<Invocation>,
results: Vec<Result>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
information_uri: Option<String>,
name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
rules: Vec<ReportingDescriptor>,
#[serde(skip_serializing_if = "Option::is_none")]
semantic_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<String>,
}
/// 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<MultiformatMessageString>,
#[serde(skip_serializing_if = "Option::is_none")]
help_uri: Option<String>,
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<PropertyBag>,
}
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<String>,
text: String,
}
/// Property bag (SARIF §3.8).
#[derive(Debug, Serialize)]
struct PropertyBag {
#[serde(skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
#[serde(flatten)]
additional_properties: BTreeMap<String, Value>,
}
/// A single finding within a run (SARIF §3.27).
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Result {
kind: ResultKind,
level: ResultLevel,
locations: Vec<Location>,
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<String, String>,
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<LogicalLocation>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
/// 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,
}
+285 -6
View File
@@ -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(())
}