Skip to main content

sbom_tools/quality/
scorer.rs

1//! SBOM Quality Scorer.
2//!
3//! Main scoring engine that combines metrics and compliance checking
4//! into an overall quality assessment.
5
6use crate::model::NormalizedSbom;
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11    CompletenessMetrics, CompletenessWeights, DependencyMetrics, IdentifierMetrics, LicenseMetrics,
12    VulnerabilityMetrics,
13};
14
15/// Scoring profile determines weights and thresholds
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum ScoringProfile {
18    /// Minimal requirements - basic identification
19    Minimal,
20    /// Standard requirements - recommended for most use cases
21    Standard,
22    /// Security-focused - emphasizes vulnerability info and supply chain
23    Security,
24    /// License-focused - emphasizes license compliance
25    LicenseCompliance,
26    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
27    Cra,
28    /// Comprehensive - all aspects equally weighted
29    Comprehensive,
30}
31
32impl ScoringProfile {
33    /// Get the compliance level associated with this profile
34    pub fn compliance_level(&self) -> ComplianceLevel {
35        match self {
36            Self::Minimal => ComplianceLevel::Minimum,
37            Self::Standard => ComplianceLevel::Standard,
38            Self::Security => ComplianceLevel::NtiaMinimum,
39            Self::LicenseCompliance => ComplianceLevel::Standard,
40            Self::Cra => ComplianceLevel::CraPhase2,
41            Self::Comprehensive => ComplianceLevel::Comprehensive,
42        }
43    }
44
45    /// Get weights for this profile
46    fn weights(&self) -> ScoringWeights {
47        match self {
48            Self::Minimal => ScoringWeights {
49                completeness: 0.5,
50                identifiers: 0.2,
51                licenses: 0.1,
52                vulnerabilities: 0.1,
53                dependencies: 0.1,
54            },
55            Self::Standard => ScoringWeights {
56                completeness: 0.35,
57                identifiers: 0.25,
58                licenses: 0.15,
59                vulnerabilities: 0.1,
60                dependencies: 0.15,
61            },
62            Self::Security => ScoringWeights {
63                completeness: 0.2,
64                identifiers: 0.25,
65                licenses: 0.1,
66                vulnerabilities: 0.3,
67                dependencies: 0.15,
68            },
69            Self::LicenseCompliance => ScoringWeights {
70                completeness: 0.2,
71                identifiers: 0.15,
72                licenses: 0.4,
73                vulnerabilities: 0.1,
74                dependencies: 0.15,
75            },
76            Self::Cra => ScoringWeights {
77                completeness: 0.2,    // Supplier info matters
78                identifiers: 0.25,    // Traceability is key
79                licenses: 0.1,        // Less emphasized by CRA
80                vulnerabilities: 0.25, // Security disclosure critical
81                dependencies: 0.2,    // Supply chain transparency
82            },
83            Self::Comprehensive => ScoringWeights {
84                completeness: 0.25,
85                identifiers: 0.2,
86                licenses: 0.2,
87                vulnerabilities: 0.15,
88                dependencies: 0.2,
89            },
90        }
91    }
92}
93
94/// Weights for overall score calculation
95#[derive(Debug, Clone)]
96struct ScoringWeights {
97    completeness: f32,
98    identifiers: f32,
99    licenses: f32,
100    vulnerabilities: f32,
101    dependencies: f32,
102}
103
104/// Quality grade based on score
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106pub enum QualityGrade {
107    /// Excellent: 90-100
108    A,
109    /// Good: 80-89
110    B,
111    /// Fair: 70-79
112    C,
113    /// Poor: 60-69
114    D,
115    /// Failing: <60
116    F,
117}
118
119impl QualityGrade {
120    /// Create grade from score
121    pub fn from_score(score: f32) -> Self {
122        match score as u32 {
123            90..=100 => Self::A,
124            80..=89 => Self::B,
125            70..=79 => Self::C,
126            60..=69 => Self::D,
127            _ => Self::F,
128        }
129    }
130
131    /// Get grade letter
132    pub fn letter(&self) -> &'static str {
133        match self {
134            Self::A => "A",
135            Self::B => "B",
136            Self::C => "C",
137            Self::D => "D",
138            Self::F => "F",
139        }
140    }
141
142    /// Get grade description
143    pub fn description(&self) -> &'static str {
144        match self {
145            Self::A => "Excellent",
146            Self::B => "Good",
147            Self::C => "Fair",
148            Self::D => "Poor",
149            Self::F => "Failing",
150        }
151    }
152}
153
154/// Recommendation for improving quality
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Recommendation {
157    /// Priority (1 = highest, 5 = lowest)
158    pub priority: u8,
159    /// Category of the recommendation
160    pub category: RecommendationCategory,
161    /// Human-readable message
162    pub message: String,
163    /// Estimated impact on score (0-100)
164    pub impact: f32,
165    /// Affected components (if applicable)
166    pub affected_count: usize,
167}
168
169/// Category for recommendations
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171pub enum RecommendationCategory {
172    Completeness,
173    Identifiers,
174    Licenses,
175    Vulnerabilities,
176    Dependencies,
177    Compliance,
178}
179
180impl RecommendationCategory {
181    pub fn name(&self) -> &'static str {
182        match self {
183            Self::Completeness => "Completeness",
184            Self::Identifiers => "Identifiers",
185            Self::Licenses => "Licenses",
186            Self::Vulnerabilities => "Vulnerabilities",
187            Self::Dependencies => "Dependencies",
188            Self::Compliance => "Compliance",
189        }
190    }
191}
192
193/// Complete quality report for an SBOM
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct QualityReport {
196    /// Overall score (0-100)
197    pub overall_score: f32,
198    /// Overall grade
199    pub grade: QualityGrade,
200    /// Scoring profile used
201    pub profile: ScoringProfile,
202    /// Completeness score (0-100)
203    pub completeness_score: f32,
204    /// Identifier quality score (0-100)
205    pub identifier_score: f32,
206    /// License quality score (0-100)
207    pub license_score: f32,
208    /// Vulnerability documentation score (0-100)
209    pub vulnerability_score: f32,
210    /// Dependency graph quality score (0-100)
211    pub dependency_score: f32,
212    /// Detailed completeness metrics
213    pub completeness_metrics: CompletenessMetrics,
214    /// Detailed identifier metrics
215    pub identifier_metrics: IdentifierMetrics,
216    /// Detailed license metrics
217    pub license_metrics: LicenseMetrics,
218    /// Detailed vulnerability metrics
219    pub vulnerability_metrics: VulnerabilityMetrics,
220    /// Detailed dependency metrics
221    pub dependency_metrics: DependencyMetrics,
222    /// Compliance check result
223    pub compliance: ComplianceResult,
224    /// Prioritized recommendations
225    pub recommendations: Vec<Recommendation>,
226}
227
228/// Quality scorer for SBOMs
229#[derive(Debug, Clone)]
230pub struct QualityScorer {
231    /// Scoring profile
232    profile: ScoringProfile,
233    /// Completeness weights
234    completeness_weights: CompletenessWeights,
235}
236
237impl QualityScorer {
238    /// Create a new quality scorer with the given profile
239    pub fn new(profile: ScoringProfile) -> Self {
240        Self {
241            profile,
242            completeness_weights: CompletenessWeights::default(),
243        }
244    }
245
246    /// Set custom completeness weights
247    pub fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
248        self.completeness_weights = weights;
249        self
250    }
251
252    /// Score an SBOM
253    pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
254        let total_components = sbom.components.len();
255
256        // Calculate metrics
257        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
258        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
259        let license_metrics = LicenseMetrics::from_sbom(sbom);
260        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
261        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
262
263        // Calculate individual scores
264        let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
265        let identifier_score = identifier_metrics.quality_score(total_components);
266        let license_score = license_metrics.quality_score(total_components);
267        let vulnerability_score = vulnerability_metrics.documentation_score();
268        let dependency_score = dependency_metrics.quality_score(total_components);
269
270        // Calculate weighted overall score
271        let weights = self.profile.weights();
272        let overall_score = (completeness_score * weights.completeness
273            + identifier_score * weights.identifiers
274            + license_score * weights.licenses
275            + vulnerability_score * weights.vulnerabilities
276            + dependency_score * weights.dependencies)
277            .min(100.0);
278
279        // Run compliance check
280        let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
281        let compliance = compliance_checker.check(sbom);
282
283        // Generate recommendations
284        let recommendations = self.generate_recommendations(
285            &completeness_metrics,
286            &identifier_metrics,
287            &license_metrics,
288            &dependency_metrics,
289            &compliance,
290            total_components,
291        );
292
293        QualityReport {
294            overall_score,
295            grade: QualityGrade::from_score(overall_score),
296            profile: self.profile,
297            completeness_score,
298            identifier_score,
299            license_score,
300            vulnerability_score,
301            dependency_score,
302            completeness_metrics,
303            identifier_metrics,
304            license_metrics,
305            vulnerability_metrics,
306            dependency_metrics,
307            compliance,
308            recommendations,
309        }
310    }
311
312    fn generate_recommendations(
313        &self,
314        completeness: &CompletenessMetrics,
315        identifiers: &IdentifierMetrics,
316        licenses: &LicenseMetrics,
317        dependencies: &DependencyMetrics,
318        compliance: &ComplianceResult,
319        total_components: usize,
320    ) -> Vec<Recommendation> {
321        let mut recommendations = Vec::new();
322
323        // Priority 1: Compliance errors
324        if compliance.error_count > 0 {
325            recommendations.push(Recommendation {
326                priority: 1,
327                category: RecommendationCategory::Compliance,
328                message: format!(
329                    "Fix {} compliance error(s) to meet {} requirements",
330                    compliance.error_count,
331                    compliance.level.name()
332                ),
333                impact: 20.0,
334                affected_count: compliance.error_count,
335            });
336        }
337
338        // Priority 1: Missing versions (critical for identification)
339        let missing_versions = total_components
340            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
341        if missing_versions > 0 {
342            recommendations.push(Recommendation {
343                priority: 1,
344                category: RecommendationCategory::Completeness,
345                message: "Add version information to all components".to_string(),
346                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
347                affected_count: missing_versions,
348            });
349        }
350
351        // Priority 2: Missing PURLs (important for identification)
352        if identifiers.missing_all_identifiers > 0 {
353            recommendations.push(Recommendation {
354                priority: 2,
355                category: RecommendationCategory::Identifiers,
356                message: "Add PURL or CPE identifiers to components".to_string(),
357                impact: (identifiers.missing_all_identifiers as f32
358                    / total_components.max(1) as f32)
359                    * 20.0,
360                affected_count: identifiers.missing_all_identifiers,
361            });
362        }
363
364        // Priority 2: Invalid identifiers
365        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
366        if invalid_ids > 0 {
367            recommendations.push(Recommendation {
368                priority: 2,
369                category: RecommendationCategory::Identifiers,
370                message: "Fix malformed PURL/CPE identifiers".to_string(),
371                impact: 10.0,
372                affected_count: invalid_ids,
373            });
374        }
375
376        // Priority 3: Missing licenses
377        let missing_licenses = total_components - licenses.with_declared;
378        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
379        {
380            recommendations.push(Recommendation {
381                priority: 3,
382                category: RecommendationCategory::Licenses,
383                message: "Add license information to components".to_string(),
384                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
385                affected_count: missing_licenses,
386            });
387        }
388
389        // Priority 3: NOASSERTION licenses
390        if licenses.noassertion_count > 0 {
391            recommendations.push(Recommendation {
392                priority: 3,
393                category: RecommendationCategory::Licenses,
394                message: "Replace NOASSERTION with actual license information".to_string(),
395                impact: 5.0,
396                affected_count: licenses.noassertion_count,
397            });
398        }
399
400        // Priority 4: Non-standard licenses
401        if licenses.non_standard_licenses > 0 {
402            recommendations.push(Recommendation {
403                priority: 4,
404                category: RecommendationCategory::Licenses,
405                message: "Use SPDX license identifiers for better interoperability".to_string(),
406                impact: 3.0,
407                affected_count: licenses.non_standard_licenses,
408            });
409        }
410
411        // Priority 4: Missing dependency information
412        if total_components > 1 && dependencies.total_dependencies == 0 {
413            recommendations.push(Recommendation {
414                priority: 4,
415                category: RecommendationCategory::Dependencies,
416                message: "Add dependency relationships between components".to_string(),
417                impact: 10.0,
418                affected_count: total_components,
419            });
420        }
421
422        // Priority 4: Many orphan components
423        if dependencies.orphan_components > 1
424            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
425        {
426            recommendations.push(Recommendation {
427                priority: 4,
428                category: RecommendationCategory::Dependencies,
429                message: "Review orphan components that have no dependency relationships"
430                    .to_string(),
431                impact: 5.0,
432                affected_count: dependencies.orphan_components,
433            });
434        }
435
436        // Priority 5: Missing supplier information
437        let missing_suppliers = total_components
438            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
439        if missing_suppliers > 0
440            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
441        {
442            recommendations.push(Recommendation {
443                priority: 5,
444                category: RecommendationCategory::Completeness,
445                message: "Add supplier information to components".to_string(),
446                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
447                affected_count: missing_suppliers,
448            });
449        }
450
451        // Priority 5: Missing hashes
452        let missing_hashes = total_components
453            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
454        if missing_hashes > 0
455            && matches!(
456                self.profile,
457                ScoringProfile::Security | ScoringProfile::Comprehensive
458            )
459        {
460            recommendations.push(Recommendation {
461                priority: 5,
462                category: RecommendationCategory::Completeness,
463                message: "Add cryptographic hashes for integrity verification".to_string(),
464                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
465                affected_count: missing_hashes,
466            });
467        }
468
469        // Sort by priority, then by impact
470        recommendations.sort_by(|a, b| {
471            a.priority.cmp(&b.priority).then_with(|| {
472                b.impact
473                    .partial_cmp(&a.impact)
474                    .unwrap_or(std::cmp::Ordering::Equal)
475            })
476        });
477
478        recommendations
479    }
480}
481
482impl Default for QualityScorer {
483    fn default() -> Self {
484        Self::new(ScoringProfile::Standard)
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_grade_from_score() {
494        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
495        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
496        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
497        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
498        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
499    }
500
501    #[test]
502    fn test_scoring_profile_compliance_level() {
503        assert_eq!(
504            ScoringProfile::Minimal.compliance_level(),
505            ComplianceLevel::Minimum
506        );
507        assert_eq!(
508            ScoringProfile::Security.compliance_level(),
509            ComplianceLevel::NtiaMinimum
510        );
511        assert_eq!(
512            ScoringProfile::Comprehensive.compliance_level(),
513            ComplianceLevel::Comprehensive
514        );
515    }
516}