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