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, skip_serializing_if = "Vec::is_empty")]
pub metadata_changes: Vec<MetadataChange>,
#[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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ml_regressions: Vec<MlRegression>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MlRegression {
pub component: String,
pub metric: String,
pub previous_value: f64,
pub new_value: f64,
}
#[must_use]
pub fn ml_metric_higher_is_better(metric: &str) -> Option<bool> {
let metric = metric.split('@').next().unwrap_or(metric);
match metric {
"accuracy" | "f1" | "f1_score" | "precision" | "recall" | "auc" | "roc_auc" | "bleu"
| "rouge" => Some(true),
"loss" | "error" | "error_rate" | "perplexity" | "latency" | "latency_ms" => Some(false),
_ => None,
}
}
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,
metadata_changes: Vec::new(),
graph_changes: Vec::new(),
graph_summary: None,
rules_applied: 0,
quality_delta: None,
match_metrics: None,
ml_regressions: Vec::new(),
}
}
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
.iter()
.filter(|c| c.change_type != ChangeType::Unchanged)
.count();
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.metadata_changes_count = self.metadata_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.metadata_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.added.is_empty()
|| !self.components.removed.is_empty()
|| self
.components
.modified
.iter()
.any(|c| c.change_type != ChangeType::Unchanged)
|| !self.dependencies.is_empty()
|| !self.graph_changes.is_empty()
|| !self.metadata_changes.is_empty()
|| !self.vulnerabilities.introduced.is_empty()
|| !self.vulnerabilities.resolved.is_empty()
}
pub fn refresh_derived_day_counts(&mut self) -> bool {
let today = chrono::Utc::now().date_naive();
let mut changed = false;
for detail in self
.vulnerabilities
.introduced
.iter_mut()
.chain(self.vulnerabilities.resolved.iter_mut())
.chain(self.vulnerabilities.persistent.iter_mut())
{
changed |= detail.refresh_day_counts(today);
}
changed
}
#[must_use]
pub fn day_counts_stale(&self) -> bool {
let today = chrono::Utc::now().date_naive();
self.vulnerabilities
.introduced
.iter()
.chain(self.vulnerabilities.resolved.iter())
.chain(self.vulnerabilities.persistent.iter())
.any(|detail| {
let mut probe = detail.clone();
probe.refresh_day_counts(today)
})
}
#[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,
}
fn cbom_slot_scores(report: &crate::quality::QualityReport) -> Option<[Option<f32>; 8]> {
if report.profile != crate::quality::ScoringProfile::Cbom
|| !report.cryptography_metrics.has_data()
{
return None;
}
let cm = &report.cryptography_metrics;
Some([
Some(cm.crypto_completeness_score()),
Some(cm.crypto_identifier_score()),
Some(cm.algorithm_strength_score()),
Some(cm.crypto_dependency_score()),
Some(cm.crypto_lifecycle_score()),
cm.pqc_readiness_score(),
Some(report.provenance_score),
Some(report.license_score),
])
}
impl QualityDelta {
#[must_use]
pub fn from_reports(
old: &crate::quality::QualityReport,
new: &crate::quality::QualityReport,
) -> Self {
let category_deltas: Vec<CategoryDelta> =
match (cbom_slot_scores(old), cbom_slot_scores(new)) {
(Some(old_slots), Some(new_slots)) => {
crate::quality::CryptographyMetrics::cbom_category_names()
.iter()
.zip(old_slots.iter().zip(new_slots.iter()))
.filter_map(|(name, (old_s, new_s))| match (old_s, new_s) {
(Some(o), Some(n)) => Some(CategoryDelta {
category: (*name).to_string(),
old_score: *o,
new_score: *n,
delta: n - o,
}),
_ => None,
})
.collect()
}
_ => Self::standard_category_deltas(old, new),
};
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,
}
}
fn standard_category_deltas(
old: &crate::quality::QualityReport,
new: &crate::quality::QualityReport,
) -> Vec<CategoryDelta> {
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,
});
}
category_deltas
}
}
#[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 metadata_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,
"EquivalenceRule" => 0.02,
"Alias" => 0.02,
"EcosystemRule" | "NameIdentity" => 0.03,
"CustomRule" => 0.05,
"Fuzzy" => 0.08,
"CrossEcosystem" => 0.10,
_ => 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component_type: 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>,
}
fn resolved_component_type(component: &Component) -> String {
use crate::model::CryptoAssetType;
if let Some(cp) = &component.crypto_properties {
if cp.asset_type == CryptoAssetType::RelatedCryptoMaterial
&& let Some(mat) = &cp.related_crypto_material_properties
{
return mat.material_type.to_string();
}
return cp.asset_type.to_string();
}
component.component_type.to_string()
}
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),
component_type: Some(resolved_component_type(component)),
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),
component_type: Some(resolved_component_type(component)),
change_type: ChangeType::Removed,
field_changes: Vec::new(),
cost,
match_info: None,
}
}
pub fn unchanged(old: &Component, new: &Component) -> 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),
component_type: Some(resolved_component_type(new)),
change_type: ChangeType::Unchanged,
field_changes: Vec::new(),
cost: 0,
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),
component_type: Some(resolved_component_type(new)),
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),
component_type: Some(resolved_component_type(new)),
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, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetadataChangeKind {
Added,
Removed,
Modified,
}
impl std::fmt::Display for MetadataChangeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Modified => "modified",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetadataChange {
pub field: String,
pub old_value: Option<String>,
pub new_value: Option<String>,
pub kind: MetadataChangeKind,
}
impl MetadataChange {
#[must_use]
pub fn from_values(
field: impl Into<String>,
old_value: Option<String>,
new_value: Option<String>,
) -> Option<Self> {
if old_value == new_value {
return None;
}
let kind = match (&old_value, &new_value) {
(None, Some(_)) => MetadataChangeKind::Added,
(Some(_), None) => MetadataChangeKind::Removed,
_ => MetadataChangeKind::Modified,
};
Some(Self {
field: field.into(),
old_value,
new_value,
kind,
})
}
}
#[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 is_ransomware: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epss_score: Option<f64>,
#[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| {
let today = chrono::Utc::now().date_naive();
(
Some(kev.due_date.format("%Y-%m-%d").to_string()),
Some((kev.due_date.date_naive() - today).num_days()),
)
});
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,
is_ransomware: vuln.is_ransomware_related(),
epss_score: vuln.epss_score,
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())
},
}
}
pub(crate) fn refresh_day_counts(&mut self, today: chrono::NaiveDate) -> bool {
let mut changed = false;
if let Some(published) = self.published_date.as_deref()
&& let Ok(date) = chrono::NaiveDate::parse_from_str(published, "%Y-%m-%d")
{
let days = (today - date).num_days();
if self.days_since_published != Some(days) {
self.days_since_published = Some(days);
changed = true;
}
}
if let Some(due) = self.kev_due_date.as_deref()
&& let Ok(date) = chrono::NaiveDate::parse_from_str(due, "%Y-%m-%d")
{
let days = (date - today).num_days();
if self.days_until_due != Some(days) {
self.days_until_due = Some(days);
changed = true;
}
}
changed
}
#[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,
}
#[cfg(test)]
mod quality_delta_tests {
use super::QualityDelta;
use crate::model::{
AlgorithmProperties, Component, ComponentType, CryptoAssetType, CryptoPrimitive,
CryptoProperties, NormalizedSbom,
};
use crate::quality::{CryptographyMetrics, QualityScorer, ScoringProfile};
fn cbom_sbom(family: &str, quantum_level: u8) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new(family.to_string(), format!("crypto/{family}"));
comp.component_type = ComponentType::Cryptographic;
comp.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Hash)
.with_algorithm_family(family.to_string())
.with_nist_quantum_security_level(quantum_level),
),
);
comp.calculate_content_hash();
sbom.add_component(comp);
sbom
}
fn plain_sbom(version: &str) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("lodash".to_string(), format!("pkg:npm/lodash@{version}"));
comp.version = Some(version.to_string());
comp.calculate_content_hash();
sbom.add_component(comp);
sbom
}
#[test]
fn cbom_pair_uses_crypto_category_names() {
let scorer = QualityScorer::new(ScoringProfile::Cbom);
let old_report = scorer.score(&cbom_sbom("SHA-2", 1));
let new_report = scorer.score(&cbom_sbom("MD5", 0));
let delta = QualityDelta::from_reports(&old_report, &new_report);
let names: Vec<&str> = delta
.category_deltas
.iter()
.map(|d| d.category.as_str())
.collect();
assert_eq!(
names,
CryptographyMetrics::cbom_category_names().to_vec(),
"CBOM delta must use the scorer's crypto category names"
);
for name in delta.regressions.iter().chain(delta.improvements.iter()) {
assert!(
CryptographyMetrics::cbom_category_names().contains(&name.as_str()),
"unexpected non-CBOM category `{name}` in a CBOM delta"
);
}
assert!(
delta.regressions.iter().any(|r| r == "Algo Strength"),
"expected an Algo Strength regression, got {:?}",
delta.regressions
);
}
#[test]
fn standard_pair_keeps_generic_category_names() {
let scorer = QualityScorer::new(ScoringProfile::Standard);
let old_report = scorer.score(&plain_sbom("1.0.0"));
let new_report = scorer.score(&plain_sbom("2.0.0"));
let delta = QualityDelta::from_reports(&old_report, &new_report);
let names: Vec<&str> = delta
.category_deltas
.iter()
.map(|d| d.category.as_str())
.collect();
for expected in [
"Completeness",
"Identifiers",
"Licenses",
"Dependencies",
"Integrity",
"Provenance",
] {
assert!(
names.contains(&expected),
"missing `{expected}` in {names:?}"
);
}
assert!(
!names.iter().any(|n| n.starts_with("Crypto")),
"generic delta must not carry CBOM names: {names:?}"
);
}
#[test]
fn mixed_profile_pair_falls_back_to_generic_names() {
let old_report = QualityScorer::new(ScoringProfile::Standard).score(&plain_sbom("1.0.0"));
let new_report = QualityScorer::new(ScoringProfile::Cbom).score(&cbom_sbom("SHA-2", 1));
let delta = QualityDelta::from_reports(&old_report, &new_report);
assert!(
delta
.category_deltas
.iter()
.any(|d| d.category == "Completeness"),
"mixed pair must keep generic categories"
);
}
}
#[cfg(test)]
mod ml_metric_direction_tests {
use super::ml_metric_higher_is_better;
#[test]
fn ml_metric_direction_table() {
assert_eq!(ml_metric_higher_is_better("accuracy"), Some(true));
assert_eq!(
ml_metric_higher_is_better("accuracy@validation"),
Some(true)
);
assert_eq!(ml_metric_higher_is_better("loss"), Some(false));
assert_eq!(ml_metric_higher_is_better("latency_ms"), Some(false));
assert_eq!(ml_metric_higher_is_better("custom_metric"), None);
}
}