mirror of
https://github.com/astral-sh/uv
synced 2026-06-21 13:47:25 +00:00
Support SARIF as a uv audit output (#19872)
Signed-off-by: William Woodruff <william@yossarian.net>
This commit is contained in:
@@ -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)?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user