use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn severity_rank(s: &str) -> u8 {
match s.to_lowercase().as_str() {
"critical" => 4,
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
pub struct DiffResult {
pub summary: DiffSummary,
pub components: ChangeSet<ComponentChange>,
pub dependencies: ChangeSet<DependencyChange>,
pub licenses: LicenseChanges,
pub vulnerabilities: VulnerabilityChanges,
pub semantic_score: f64,
#[serde(default)]
pub graph_changes: Vec<DependencyGraphChange>,
#[serde(default)]
pub graph_summary: Option<GraphChangeSummary>,
#[serde(default)]
pub rules_applied: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quality_delta: Option<QualityDelta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub match_metrics: Option<MatchMetrics>,
}
impl DiffResult {
pub fn new() -> Self {
Self {
summary: DiffSummary::default(),
components: ChangeSet::new(),
dependencies: ChangeSet::new(),
licenses: LicenseChanges::default(),
vulnerabilities: VulnerabilityChanges::default(),
semantic_score: 0.0,
graph_changes: Vec::new(),
graph_summary: None,
rules_applied: 0,
quality_delta: None,
match_metrics: None,
}
}
pub fn calculate_summary(&mut self) {
self.summary.components_added = self.components.added.len();
self.summary.components_removed = self.components.removed.len();
self.summary.components_modified = self.components.modified.len();
self.summary.dependencies_added = self.dependencies.added.len();
self.summary.dependencies_removed = self.dependencies.removed.len();
self.summary.graph_changes_count = self.graph_changes.len();
self.summary.total_changes = self.summary.components_added
+ self.summary.components_removed
+ self.summary.components_modified
+ self.summary.dependencies_added
+ self.summary.dependencies_removed
+ self.summary.graph_changes_count;
self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
self.summary.licenses_added = self.licenses.new_licenses.len();
self.summary.licenses_removed = self.licenses.removed_licenses.len();
}
#[must_use]
pub fn has_changes(&self) -> bool {
self.summary.total_changes > 0
|| !self.components.is_empty()
|| !self.dependencies.is_empty()
|| !self.graph_changes.is_empty()
|| !self.vulnerabilities.introduced.is_empty()
|| !self.vulnerabilities.resolved.is_empty()
}
#[must_use]
pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
let id_str = id.value();
self.components
.added
.iter()
.chain(self.components.removed.iter())
.chain(self.components.modified.iter())
.find(|c| c.id == id_str)
}
#[must_use]
pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
self.components
.added
.iter()
.chain(self.components.removed.iter())
.chain(self.components.modified.iter())
.find(|c| c.id == id_str)
}
#[must_use]
pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
self.components
.added
.iter()
.chain(self.components.removed.iter())
.chain(self.components.modified.iter())
.collect()
}
#[must_use]
pub fn find_vulns_for_component(
&self,
component_id: &CanonicalId,
) -> Vec<&VulnerabilityDetail> {
let id_str = component_id.value();
self.vulnerabilities
.introduced
.iter()
.chain(self.vulnerabilities.resolved.iter())
.chain(self.vulnerabilities.persistent.iter())
.filter(|v| v.component_id == id_str)
.collect()
}
#[must_use]
pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
self.components
.added
.iter()
.chain(&self.components.removed)
.chain(&self.components.modified)
.map(|c| (c.id.clone(), c))
.collect()
}
pub fn filter_by_severity(&mut self, min_severity: &str) {
let min_sev = severity_rank(min_severity);
self.vulnerabilities
.introduced
.retain(|v| severity_rank(&v.severity) >= min_sev);
self.vulnerabilities
.resolved
.retain(|v| severity_rank(&v.severity) >= min_sev);
self.vulnerabilities
.persistent
.retain(|v| severity_rank(&v.severity) >= min_sev);
self.calculate_summary();
}
pub fn filter_by_vex(&mut self) {
self.vulnerabilities
.introduced
.retain(VulnerabilityDetail::is_vex_actionable);
self.vulnerabilities
.resolved
.retain(VulnerabilityDetail::is_vex_actionable);
self.vulnerabilities
.persistent
.retain(VulnerabilityDetail::is_vex_actionable);
self.calculate_summary();
}
}
impl Default for DiffResult {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QualityDelta {
pub overall_score_delta: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_grade: Option<crate::quality::QualityGrade>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_grade: Option<crate::quality::QualityGrade>,
pub category_deltas: Vec<CategoryDelta>,
pub regressions: Vec<String>,
pub improvements: Vec<String>,
pub violation_count_delta: i32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CategoryDelta {
pub category: String,
pub old_score: f32,
pub new_score: f32,
pub delta: f32,
}
impl QualityDelta {
#[must_use]
pub fn from_reports(
old: &crate::quality::QualityReport,
new: &crate::quality::QualityReport,
) -> Self {
let categories = [
(
"Completeness",
old.completeness_score,
new.completeness_score,
),
("Identifiers", old.identifier_score, new.identifier_score),
("Licenses", old.license_score, new.license_score),
("Dependencies", old.dependency_score, new.dependency_score),
("Integrity", old.integrity_score, new.integrity_score),
("Provenance", old.provenance_score, new.provenance_score),
];
let mut category_deltas: Vec<CategoryDelta> = categories
.iter()
.map(|(name, old_s, new_s)| CategoryDelta {
category: (*name).to_string(),
old_score: *old_s,
new_score: *new_s,
delta: new_s - old_s,
})
.collect();
if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
category_deltas.push(CategoryDelta {
category: "VulnDocs".to_string(),
old_score: old_v,
new_score: new_v,
delta: new_v - old_v,
});
}
if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
category_deltas.push(CategoryDelta {
category: "Lifecycle".to_string(),
old_score: old_l,
new_score: new_l,
delta: new_l - old_l,
});
}
let regressions: Vec<String> = category_deltas
.iter()
.filter(|d| d.delta < -1.0)
.map(|d| d.category.clone())
.collect();
let improvements: Vec<String> = category_deltas
.iter()
.filter(|d| d.delta > 1.0)
.map(|d| d.category.clone())
.collect();
let old_violations = old.compliance.error_count + old.compliance.warning_count;
let new_violations = new.compliance.error_count + new.compliance.warning_count;
Self {
overall_score_delta: new.overall_score - old.overall_score,
old_grade: Some(old.grade),
new_grade: Some(new.grade),
category_deltas,
regressions,
improvements,
violation_count_delta: new_violations as i32 - old_violations as i32,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MatchMetrics {
pub exact_matches: usize,
pub fuzzy_matches: usize,
pub rule_matches: usize,
pub unmatched_old: usize,
pub unmatched_new: usize,
pub avg_match_score: f64,
pub min_match_score: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiffSummary {
pub total_changes: usize,
pub components_added: usize,
pub components_removed: usize,
pub components_modified: usize,
pub dependencies_added: usize,
pub dependencies_removed: usize,
pub graph_changes_count: usize,
pub vulnerabilities_introduced: usize,
pub vulnerabilities_resolved: usize,
pub vulnerabilities_persistent: usize,
pub licenses_added: usize,
pub licenses_removed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeSet<T> {
pub added: Vec<T>,
pub removed: Vec<T>,
pub modified: Vec<T>,
}
impl<T> ChangeSet<T> {
#[must_use]
pub const fn new() -> Self {
Self {
added: Vec::new(),
removed: Vec::new(),
modified: Vec::new(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
}
#[must_use]
pub fn total(&self) -> usize {
self.added.len() + self.removed.len() + self.modified.len()
}
}
impl<T> Default for ChangeSet<T> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchInfo {
pub score: f64,
pub method: String,
pub reason: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub score_breakdown: Vec<MatchScoreComponent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub normalizations: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence_interval: Option<ConfidenceInterval>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfidenceInterval {
pub lower: f64,
pub upper: f64,
pub level: f64,
}
impl ConfidenceInterval {
#[must_use]
pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
Self {
lower: lower.clamp(0.0, 1.0),
upper: upper.clamp(0.0, 1.0),
level,
}
}
#[must_use]
pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
let margin = 1.96 * std_error;
Self::new(score - margin, score + margin, 0.95)
}
#[must_use]
pub fn from_tier(score: f64, tier: &str) -> Self {
let margin = match tier {
"ExactIdentifier" => 0.0,
"Alias" => 0.02,
"EcosystemRule" => 0.03,
"CustomRule" => 0.05,
"Fuzzy" => 0.08,
_ => 0.10,
};
Self::new(score - margin, score + margin, 0.95)
}
#[must_use]
pub fn width(&self) -> f64 {
self.upper - self.lower
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchScoreComponent {
pub name: String,
pub weight: f64,
pub raw_score: f64,
pub weighted_score: f64,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentChange {
pub id: String,
#[serde(skip)]
pub canonical_id: Option<CanonicalId>,
#[serde(skip)]
pub component_ref: Option<ComponentRef>,
#[serde(skip)]
pub old_canonical_id: Option<CanonicalId>,
pub name: String,
pub old_version: Option<String>,
pub new_version: Option<String>,
pub ecosystem: Option<String>,
pub change_type: ChangeType,
pub field_changes: Vec<FieldChange>,
pub cost: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub match_info: Option<MatchInfo>,
}
impl ComponentChange {
pub fn added(component: &Component, cost: u32) -> Self {
Self {
id: component.canonical_id.to_string(),
canonical_id: Some(component.canonical_id.clone()),
component_ref: Some(ComponentRef::from_component(component)),
old_canonical_id: None,
name: component.name.clone(),
old_version: None,
new_version: component.version.clone(),
ecosystem: component
.ecosystem
.as_ref()
.map(std::string::ToString::to_string),
change_type: ChangeType::Added,
field_changes: Vec::new(),
cost,
match_info: None,
}
}
pub fn removed(component: &Component, cost: u32) -> Self {
Self {
id: component.canonical_id.to_string(),
canonical_id: Some(component.canonical_id.clone()),
component_ref: Some(ComponentRef::from_component(component)),
old_canonical_id: Some(component.canonical_id.clone()),
name: component.name.clone(),
old_version: component.version.clone(),
new_version: None,
ecosystem: component
.ecosystem
.as_ref()
.map(std::string::ToString::to_string),
change_type: ChangeType::Removed,
field_changes: Vec::new(),
cost,
match_info: None,
}
}
pub fn modified(
old: &Component,
new: &Component,
field_changes: Vec<FieldChange>,
cost: u32,
) -> Self {
Self {
id: new.canonical_id.to_string(),
canonical_id: Some(new.canonical_id.clone()),
component_ref: Some(ComponentRef::from_component(new)),
old_canonical_id: Some(old.canonical_id.clone()),
name: new.name.clone(),
old_version: old.version.clone(),
new_version: new.version.clone(),
ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
change_type: ChangeType::Modified,
field_changes,
cost,
match_info: None,
}
}
pub fn modified_with_match(
old: &Component,
new: &Component,
field_changes: Vec<FieldChange>,
cost: u32,
match_info: MatchInfo,
) -> Self {
Self {
id: new.canonical_id.to_string(),
canonical_id: Some(new.canonical_id.clone()),
component_ref: Some(ComponentRef::from_component(new)),
old_canonical_id: Some(old.canonical_id.clone()),
name: new.name.clone(),
old_version: old.version.clone(),
new_version: new.version.clone(),
ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
change_type: ChangeType::Modified,
field_changes,
cost,
match_info: Some(match_info),
}
}
#[must_use]
pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
self.match_info = Some(match_info);
self
}
#[must_use]
pub fn get_canonical_id(&self) -> CanonicalId {
self.canonical_id.clone().unwrap_or_else(|| {
CanonicalId::from_name_version(
&self.name,
self.new_version.as_deref().or(self.old_version.as_deref()),
)
})
}
#[must_use]
pub fn get_component_ref(&self) -> ComponentRef {
self.component_ref.clone().unwrap_or_else(|| {
ComponentRef::with_version(
self.get_canonical_id(),
&self.name,
self.new_version
.clone()
.or_else(|| self.old_version.clone()),
)
})
}
}
impl MatchInfo {
#[must_use]
pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
let method = format!("{:?}", explanation.tier);
let ci = ConfidenceInterval::from_tier(explanation.score, &method);
Self {
score: explanation.score,
method,
reason: explanation.reason.clone(),
score_breakdown: explanation
.score_breakdown
.iter()
.map(|c| MatchScoreComponent {
name: c.name.clone(),
weight: c.weight,
raw_score: c.raw_score,
weighted_score: c.weighted_score,
description: c.description.clone(),
})
.collect(),
normalizations: explanation.normalizations_applied.clone(),
confidence_interval: Some(ci),
}
}
#[must_use]
pub fn simple(score: f64, method: &str, reason: &str) -> Self {
let ci = ConfidenceInterval::from_tier(score, method);
Self {
score,
method: method.to_string(),
reason: reason.to_string(),
score_breakdown: Vec::new(),
normalizations: Vec::new(),
confidence_interval: Some(ci),
}
}
#[must_use]
pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
self.confidence_interval = Some(ci);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeType {
Added,
Removed,
Modified,
Unchanged,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldChange {
pub field: String,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyChange {
pub from: String,
pub to: String,
pub relationship: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub change_type: ChangeType,
}
impl DependencyChange {
#[must_use]
pub fn added(edge: &DependencyEdge) -> Self {
Self {
from: edge.from.to_string(),
to: edge.to.to_string(),
relationship: edge.relationship.to_string(),
scope: edge.scope.as_ref().map(std::string::ToString::to_string),
change_type: ChangeType::Added,
}
}
#[must_use]
pub fn removed(edge: &DependencyEdge) -> Self {
Self {
from: edge.from.to_string(),
to: edge.to.to_string(),
relationship: edge.relationship.to_string(),
scope: edge.scope.as_ref().map(std::string::ToString::to_string),
change_type: ChangeType::Removed,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LicenseChanges {
pub new_licenses: Vec<LicenseChange>,
pub removed_licenses: Vec<LicenseChange>,
pub conflicts: Vec<LicenseConflict>,
pub component_changes: Vec<ComponentLicenseChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseChange {
pub license: String,
pub components: Vec<String>,
pub family: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseConflict {
pub license_a: String,
pub license_b: String,
pub component: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentLicenseChange {
pub component_id: String,
pub component_name: String,
pub old_licenses: Vec<String>,
pub new_licenses: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VexStatusChange {
pub vuln_id: String,
pub component_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_state: Option<crate::model::VexState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_state: Option<crate::model::VexState>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VulnerabilityChanges {
pub introduced: Vec<VulnerabilityDetail>,
pub resolved: Vec<VulnerabilityDetail>,
pub persistent: Vec<VulnerabilityDetail>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vex_changes: Vec<VexStatusChange>,
}
impl VulnerabilityChanges {
#[must_use]
pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
let mut counts = HashMap::with_capacity(5);
for vuln in &self.introduced {
*counts.entry(vuln.severity.clone()).or_insert(0) += 1;
}
counts
}
#[must_use]
pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
self.introduced
.iter()
.filter(|v| v.severity == "Critical" || v.severity == "High")
.collect()
}
pub fn vex_summary(&self) -> VexCoverageSummary {
let all_vulns: Vec<&VulnerabilityDetail> = self
.introduced
.iter()
.chain(&self.resolved)
.chain(&self.persistent)
.collect();
let total = all_vulns.len();
let mut with_vex = 0;
let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
let mut actionable = 0;
for vuln in &all_vulns {
if let Some(ref state) = vuln.vex_state {
with_vex += 1;
*by_state.entry(state.clone()).or_insert(0) += 1;
}
if vuln.is_vex_actionable() {
actionable += 1;
}
}
let introduced_without_vex = self
.introduced
.iter()
.filter(|v| v.vex_state.is_none())
.count();
let persistent_without_vex = self
.persistent
.iter()
.filter(|v| v.vex_state.is_none())
.count();
VexCoverageSummary {
total_vulns: total,
with_vex,
without_vex: total - with_vex,
actionable,
coverage_pct: if total > 0 {
(with_vex as f64 / total as f64) * 100.0
} else {
100.0
},
by_state,
introduced_without_vex,
persistent_without_vex,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
pub struct VexCoverageSummary {
pub total_vulns: usize,
pub with_vex: usize,
pub without_vex: usize,
pub actionable: usize,
pub coverage_pct: f64,
pub by_state: HashMap<crate::model::VexState, usize>,
pub introduced_without_vex: usize,
pub persistent_without_vex: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SlaStatus {
Overdue(i64),
DueSoon(i64),
OnTrack(i64),
NoDueDate,
}
impl SlaStatus {
#[must_use]
pub fn display(&self, days_since_published: Option<i64>) -> String {
match self {
Self::Overdue(days) => format!("{days}d late"),
Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
Self::NoDueDate => {
days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
}
}
}
#[must_use]
pub const fn is_overdue(&self) -> bool {
matches!(self, Self::Overdue(_))
}
#[must_use]
pub const fn is_due_soon(&self) -> bool {
matches!(self, Self::DueSoon(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnerabilityDetail {
pub id: String,
pub source: String,
pub severity: String,
pub cvss_score: Option<f32>,
pub component_id: String,
#[serde(skip)]
pub component_canonical_id: Option<CanonicalId>,
#[serde(skip)]
pub component_ref: Option<ComponentRef>,
pub component_name: String,
pub version: Option<String>,
pub cwes: Vec<String>,
pub description: Option<String>,
pub remediation: Option<String>,
#[serde(default)]
pub is_kev: bool,
#[serde(default)]
pub component_depth: Option<u32>,
#[serde(default)]
pub published_date: Option<String>,
#[serde(default)]
pub kev_due_date: Option<String>,
#[serde(default)]
pub days_since_published: Option<i64>,
#[serde(default)]
pub days_until_due: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vex_state: Option<crate::model::VexState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vex_justification: Option<crate::model::VexJustification>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vex_impact_statement: Option<String>,
}
impl VulnerabilityDetail {
#[must_use]
pub const fn is_vex_actionable(&self) -> bool {
!matches!(
self.vex_state,
Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
)
}
pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
let days_since_published = vuln.published.map(|dt| {
let today = chrono::Utc::now().date_naive();
(today - dt.date_naive()).num_days()
});
let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
(
Some(kev.due_date.format("%Y-%m-%d").to_string()),
Some(kev.days_until_due()),
)
});
Self {
id: vuln.id.clone(),
source: vuln.source.to_string(),
severity: vuln
.severity
.as_ref()
.map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
cvss_score: vuln.max_cvss_score(),
component_id: component.canonical_id.to_string(),
component_canonical_id: Some(component.canonical_id.clone()),
component_ref: Some(ComponentRef::from_component(component)),
component_name: component.name.clone(),
version: component.version.clone(),
cwes: vuln.cwes.clone(),
description: vuln.description.clone(),
remediation: vuln.remediation.as_ref().map(|r| {
format!(
"{}: {}",
r.remediation_type,
r.description.as_deref().unwrap_or("")
)
}),
is_kev: vuln.is_kev,
component_depth: None,
published_date,
kev_due_date,
days_since_published,
days_until_due,
vex_state: {
let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
vex_source.map(|v| v.status.clone())
},
vex_justification: {
let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
vex_source.and_then(|v| v.justification.clone())
},
vex_impact_statement: {
let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
vex_source.and_then(|v| v.impact_statement.clone())
},
}
}
#[must_use]
pub fn from_ref_with_depth(
vuln: &VulnerabilityRef,
component: &Component,
depth: Option<u32>,
) -> Self {
let mut detail = Self::from_ref(vuln, component);
detail.component_depth = depth;
detail
}
#[must_use]
pub fn sla_status(&self) -> SlaStatus {
if let Some(days) = self.days_until_due {
if days < 0 {
return SlaStatus::Overdue(-days);
} else if days <= 3 {
return SlaStatus::DueSoon(days);
}
return SlaStatus::OnTrack(days);
}
if let Some(age_days) = self.days_since_published {
let sla_days = match self.severity.to_lowercase().as_str() {
"critical" => 1,
"high" => 7,
"medium" => 30,
"low" => 90,
_ => return SlaStatus::NoDueDate,
};
let remaining = sla_days - age_days;
if remaining < 0 {
return SlaStatus::Overdue(-remaining);
} else if remaining <= 3 {
return SlaStatus::DueSoon(remaining);
}
return SlaStatus::OnTrack(remaining);
}
SlaStatus::NoDueDate
}
#[must_use]
pub fn get_component_id(&self) -> CanonicalId {
self.component_canonical_id.clone().unwrap_or_else(|| {
CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
})
}
#[must_use]
pub fn get_component_ref(&self) -> ComponentRef {
self.component_ref.clone().unwrap_or_else(|| {
ComponentRef::with_version(
self.get_component_id(),
&self.component_name,
self.version.clone(),
)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyGraphChange {
pub component_id: CanonicalId,
pub component_name: String,
pub change: DependencyChangeType,
pub impact: GraphChangeImpact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DependencyChangeType {
DependencyAdded {
dependency_id: CanonicalId,
dependency_name: String,
},
DependencyRemoved {
dependency_id: CanonicalId,
dependency_name: String,
},
RelationshipChanged {
dependency_id: CanonicalId,
dependency_name: String,
old_relationship: String,
new_relationship: String,
old_scope: Option<String>,
new_scope: Option<String>,
},
Reparented {
dependency_id: CanonicalId,
dependency_name: String,
old_parent_id: CanonicalId,
old_parent_name: String,
new_parent_id: CanonicalId,
new_parent_name: String,
},
DepthChanged {
old_depth: u32, new_depth: u32,
},
}
impl DependencyChangeType {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::DependencyAdded { .. } => "added",
Self::DependencyRemoved { .. } => "removed",
Self::RelationshipChanged { .. } => "relationship_changed",
Self::Reparented { .. } => "reparented",
Self::DepthChanged { .. } => "depth_changed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum GraphChangeImpact {
Low,
Medium,
High,
Critical,
}
impl GraphChangeImpact {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
#[must_use]
pub fn from_label(s: &str) -> Self {
match s.to_lowercase().as_str() {
"critical" => Self::Critical,
"high" => Self::High,
"medium" => Self::Medium,
_ => Self::Low,
}
}
}
impl std::fmt::Display for GraphChangeImpact {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphChangeSummary {
pub total_changes: usize,
pub dependencies_added: usize,
pub dependencies_removed: usize,
pub relationship_changed: usize,
pub reparented: usize,
pub depth_changed: usize,
pub by_impact: GraphChangesByImpact,
}
impl GraphChangeSummary {
#[must_use]
pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
let mut summary = Self {
total_changes: changes.len(),
..Default::default()
};
for change in changes {
match &change.change {
DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
DependencyChangeType::RelationshipChanged { .. } => {
summary.relationship_changed += 1;
}
DependencyChangeType::Reparented { .. } => summary.reparented += 1,
DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
}
match change.impact {
GraphChangeImpact::Low => summary.by_impact.low += 1,
GraphChangeImpact::Medium => summary.by_impact.medium += 1,
GraphChangeImpact::High => summary.by_impact.high += 1,
GraphChangeImpact::Critical => summary.by_impact.critical += 1,
}
}
summary
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphChangesByImpact {
pub low: usize,
pub medium: usize,
pub high: usize,
pub critical: usize,
}