1use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11 AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, DependencyMetrics,
12 HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics, ProvenanceMetrics,
13 VulnerabilityMetrics,
14};
15
16pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23 Minimal,
25 Standard,
27 Security,
29 LicenseCompliance,
31 Cra,
33 Comprehensive,
35}
36
37impl ScoringProfile {
38 #[must_use]
40 pub const fn compliance_level(&self) -> ComplianceLevel {
41 match self {
42 Self::Minimal => ComplianceLevel::Minimum,
43 Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
44 Self::Security => ComplianceLevel::NtiaMinimum,
45 Self::Cra => ComplianceLevel::CraPhase2,
46 Self::Comprehensive => ComplianceLevel::Comprehensive,
47 }
48 }
49
50 const fn weights(self) -> ScoringWeights {
55 match self {
56 Self::Minimal => ScoringWeights {
57 completeness: 0.35,
58 identifiers: 0.20,
59 licenses: 0.10,
60 vulnerabilities: 0.05,
61 dependencies: 0.10,
62 integrity: 0.05,
63 provenance: 0.10,
64 lifecycle: 0.05,
65 },
66 Self::Standard => ScoringWeights {
67 completeness: 0.25,
68 identifiers: 0.20,
69 licenses: 0.12,
70 vulnerabilities: 0.08,
71 dependencies: 0.10,
72 integrity: 0.08,
73 provenance: 0.10,
74 lifecycle: 0.07,
75 },
76 Self::Security => ScoringWeights {
77 completeness: 0.12,
78 identifiers: 0.18,
79 licenses: 0.05,
80 vulnerabilities: 0.20,
81 dependencies: 0.10,
82 integrity: 0.15,
83 provenance: 0.10,
84 lifecycle: 0.10,
85 },
86 Self::LicenseCompliance => ScoringWeights {
87 completeness: 0.15,
88 identifiers: 0.12,
89 licenses: 0.35,
90 vulnerabilities: 0.05,
91 dependencies: 0.10,
92 integrity: 0.05,
93 provenance: 0.10,
94 lifecycle: 0.08,
95 },
96 Self::Cra => ScoringWeights {
97 completeness: 0.12,
98 identifiers: 0.18,
99 licenses: 0.08,
100 vulnerabilities: 0.15,
101 dependencies: 0.12,
102 integrity: 0.12,
103 provenance: 0.15,
104 lifecycle: 0.08,
105 },
106 Self::Comprehensive => ScoringWeights {
107 completeness: 0.15,
108 identifiers: 0.13,
109 licenses: 0.13,
110 vulnerabilities: 0.10,
111 dependencies: 0.12,
112 integrity: 0.12,
113 provenance: 0.13,
114 lifecycle: 0.12,
115 },
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
122struct ScoringWeights {
123 completeness: f32,
124 identifiers: f32,
125 licenses: f32,
126 vulnerabilities: f32,
127 dependencies: f32,
128 integrity: f32,
129 provenance: f32,
130 lifecycle: f32,
131}
132
133impl ScoringWeights {
134 fn as_array(&self) -> [f32; 8] {
136 [
137 self.completeness,
138 self.identifiers,
139 self.licenses,
140 self.vulnerabilities,
141 self.dependencies,
142 self.integrity,
143 self.provenance,
144 self.lifecycle,
145 ]
146 }
147
148 fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
153 let raw = self.as_array();
154 let total_available: f32 = raw
155 .iter()
156 .zip(available)
157 .filter(|&(_, a)| *a)
158 .map(|(w, _)| w)
159 .sum();
160
161 if total_available <= 0.0 {
162 return [0.0; 8];
163 }
164
165 let scale = 1.0 / total_available;
166 let mut result = [0.0_f32; 8];
167 for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
168 result[i] = if avail { w * scale } else { 0.0 };
169 }
170 result
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[non_exhaustive]
177pub enum QualityGrade {
178 A,
180 B,
182 C,
184 D,
186 F,
188}
189
190impl QualityGrade {
191 #[must_use]
193 pub const fn from_score(score: f32) -> Self {
194 match score as u32 {
195 90..=100 => Self::A,
196 80..=89 => Self::B,
197 70..=79 => Self::C,
198 60..=69 => Self::D,
199 _ => Self::F,
200 }
201 }
202
203 #[must_use]
205 pub const fn letter(&self) -> &'static str {
206 match self {
207 Self::A => "A",
208 Self::B => "B",
209 Self::C => "C",
210 Self::D => "D",
211 Self::F => "F",
212 }
213 }
214
215 #[must_use]
217 pub const fn description(&self) -> &'static str {
218 match self {
219 Self::A => "Excellent",
220 Self::B => "Good",
221 Self::C => "Fair",
222 Self::D => "Poor",
223 Self::F => "Failing",
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Recommendation {
231 pub priority: u8,
233 pub category: RecommendationCategory,
235 pub message: String,
237 pub impact: f32,
239 pub affected_count: usize,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[non_exhaustive]
246pub enum RecommendationCategory {
247 Completeness,
248 Identifiers,
249 Licenses,
250 Vulnerabilities,
251 Dependencies,
252 Compliance,
253 Integrity,
254 Provenance,
255 Lifecycle,
256}
257
258impl RecommendationCategory {
259 #[must_use]
260 pub const fn name(&self) -> &'static str {
261 match self {
262 Self::Completeness => "Completeness",
263 Self::Identifiers => "Identifiers",
264 Self::Licenses => "Licenses",
265 Self::Vulnerabilities => "Vulnerabilities",
266 Self::Dependencies => "Dependencies",
267 Self::Compliance => "Compliance",
268 Self::Integrity => "Integrity",
269 Self::Provenance => "Provenance",
270 Self::Lifecycle => "Lifecycle",
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277#[must_use]
278pub struct QualityReport {
279 pub scoring_engine_version: String,
281 pub overall_score: f32,
283 pub grade: QualityGrade,
285 pub profile: ScoringProfile,
287
288 pub completeness_score: f32,
291 pub identifier_score: f32,
293 pub license_score: f32,
295 pub vulnerability_score: f32,
297 pub dependency_score: f32,
299 pub integrity_score: f32,
301 pub provenance_score: f32,
303 pub lifecycle_score: Option<f32>,
305
306 pub completeness_metrics: CompletenessMetrics,
309 pub identifier_metrics: IdentifierMetrics,
311 pub license_metrics: LicenseMetrics,
313 pub vulnerability_metrics: VulnerabilityMetrics,
315 pub dependency_metrics: DependencyMetrics,
317 pub hash_quality_metrics: HashQualityMetrics,
319 pub provenance_metrics: ProvenanceMetrics,
321 pub auditability_metrics: AuditabilityMetrics,
323 pub lifecycle_metrics: LifecycleMetrics,
325
326 pub compliance: ComplianceResult,
328 pub recommendations: Vec<Recommendation>,
330}
331
332#[derive(Debug, Clone)]
334pub struct QualityScorer {
335 profile: ScoringProfile,
337 completeness_weights: CompletenessWeights,
339}
340
341impl QualityScorer {
342 #[must_use]
344 pub fn new(profile: ScoringProfile) -> Self {
345 Self {
346 profile,
347 completeness_weights: CompletenessWeights::default(),
348 }
349 }
350
351 #[must_use]
353 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
354 self.completeness_weights = weights;
355 self
356 }
357
358 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
360 let total_components = sbom.components.len();
361 let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
362
363 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
365 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
366 let license_metrics = LicenseMetrics::from_sbom(sbom);
367 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
368 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
369 let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
370 let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
371 let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
372 let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
373
374 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
376 let identifier_score = identifier_metrics.quality_score(total_components);
377 let license_score = license_metrics.quality_score(total_components);
378 let vulnerability_score = vulnerability_metrics.documentation_score();
379 let dependency_score = dependency_metrics.quality_score(total_components);
380 let integrity_score = hash_quality_metrics.quality_score(total_components);
381 let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
382 let auditability_raw = auditability_metrics.quality_score(total_components);
383 let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
385 let lifecycle_score = lifecycle_metrics.quality_score();
386
387 let lifecycle_available = lifecycle_score.is_some();
389 let available = [
390 true, true, true, true, true, true, true, lifecycle_available, ];
399
400 let weights = self.profile.weights();
402 let norm = weights.renormalize(&available);
403 let scores = [
404 completeness_score,
405 identifier_score,
406 license_score,
407 vulnerability_score,
408 dependency_score,
409 integrity_score,
410 provenance_score,
411 lifecycle_score.unwrap_or(0.0),
412 ];
413
414 let mut overall_score: f32 = scores
415 .iter()
416 .zip(norm.iter())
417 .map(|(s, w)| s * w)
418 .sum();
419 overall_score = overall_score.min(100.0);
420
421 overall_score = self.apply_score_caps(
423 overall_score,
424 &lifecycle_metrics,
425 &dependency_metrics,
426 &hash_quality_metrics,
427 total_components,
428 );
429
430 let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
432 let compliance = compliance_checker.check(sbom);
433
434 let recommendations = self.generate_recommendations(
436 &completeness_metrics,
437 &identifier_metrics,
438 &license_metrics,
439 &dependency_metrics,
440 &hash_quality_metrics,
441 &provenance_metrics,
442 &lifecycle_metrics,
443 &compliance,
444 total_components,
445 );
446
447 QualityReport {
448 scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
449 overall_score,
450 grade: QualityGrade::from_score(overall_score),
451 profile: self.profile,
452 completeness_score,
453 identifier_score,
454 license_score,
455 vulnerability_score,
456 dependency_score,
457 integrity_score,
458 provenance_score,
459 lifecycle_score,
460 completeness_metrics,
461 identifier_metrics,
462 license_metrics,
463 vulnerability_metrics,
464 dependency_metrics,
465 hash_quality_metrics,
466 provenance_metrics,
467 auditability_metrics,
468 lifecycle_metrics,
469 compliance,
470 recommendations,
471 }
472 }
473
474 fn apply_score_caps(
476 &self,
477 mut score: f32,
478 lifecycle: &LifecycleMetrics,
479 deps: &DependencyMetrics,
480 hashes: &HashQualityMetrics,
481 total_components: usize,
482 ) -> f32 {
483 let is_security_profile = matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
484
485 if is_security_profile && lifecycle.eol_components > 0 {
487 score = score.min(69.0);
488 }
489
490 if deps.cycle_count > 0
492 && matches!(
493 self.profile,
494 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
495 )
496 {
497 score = score.min(89.0);
498 }
499
500 if matches!(self.profile, ScoringProfile::Security)
502 && total_components > 0
503 && hashes.components_with_any_hash == 0
504 {
505 score = score.min(79.0);
506 }
507
508 if matches!(self.profile, ScoringProfile::Security)
510 && hashes.components_with_weak_only > 0
511 && hashes.components_with_strong_hash == 0
512 {
513 score = score.min(89.0);
514 }
515
516 score
517 }
518
519 #[allow(clippy::too_many_arguments)]
520 fn generate_recommendations(
521 &self,
522 completeness: &CompletenessMetrics,
523 identifiers: &IdentifierMetrics,
524 licenses: &LicenseMetrics,
525 dependencies: &DependencyMetrics,
526 hashes: &HashQualityMetrics,
527 provenance: &ProvenanceMetrics,
528 lifecycle: &LifecycleMetrics,
529 compliance: &ComplianceResult,
530 total_components: usize,
531 ) -> Vec<Recommendation> {
532 let mut recommendations = Vec::new();
533
534 if compliance.error_count > 0 {
536 recommendations.push(Recommendation {
537 priority: 1,
538 category: RecommendationCategory::Compliance,
539 message: format!(
540 "Fix {} compliance error(s) to meet {} requirements",
541 compliance.error_count,
542 compliance.level.name()
543 ),
544 impact: 20.0,
545 affected_count: compliance.error_count,
546 });
547 }
548
549 if lifecycle.eol_components > 0 {
551 recommendations.push(Recommendation {
552 priority: 1,
553 category: RecommendationCategory::Lifecycle,
554 message: format!(
555 "{} component(s) have reached end-of-life — upgrade or replace",
556 lifecycle.eol_components
557 ),
558 impact: 15.0,
559 affected_count: lifecycle.eol_components,
560 });
561 }
562
563 let missing_versions = total_components
565 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
566 if missing_versions > 0 {
567 recommendations.push(Recommendation {
568 priority: 1,
569 category: RecommendationCategory::Completeness,
570 message: "Add version information to all components".to_string(),
571 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
572 affected_count: missing_versions,
573 });
574 }
575
576 if hashes.components_with_weak_only > 0 {
578 recommendations.push(Recommendation {
579 priority: 2,
580 category: RecommendationCategory::Integrity,
581 message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
582 impact: 10.0,
583 affected_count: hashes.components_with_weak_only,
584 });
585 }
586
587 if identifiers.missing_all_identifiers > 0 {
589 recommendations.push(Recommendation {
590 priority: 2,
591 category: RecommendationCategory::Identifiers,
592 message: "Add PURL or CPE identifiers to components".to_string(),
593 impact: (identifiers.missing_all_identifiers as f32
594 / total_components.max(1) as f32)
595 * 20.0,
596 affected_count: identifiers.missing_all_identifiers,
597 });
598 }
599
600 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
602 if invalid_ids > 0 {
603 recommendations.push(Recommendation {
604 priority: 2,
605 category: RecommendationCategory::Identifiers,
606 message: "Fix malformed PURL/CPE identifiers".to_string(),
607 impact: 10.0,
608 affected_count: invalid_ids,
609 });
610 }
611
612 if !provenance.has_tool_creator {
614 recommendations.push(Recommendation {
615 priority: 2,
616 category: RecommendationCategory::Provenance,
617 message: "Add SBOM creation tool information".to_string(),
618 impact: 8.0,
619 affected_count: 0,
620 });
621 }
622
623 if dependencies.cycle_count > 0 {
625 recommendations.push(Recommendation {
626 priority: 3,
627 category: RecommendationCategory::Dependencies,
628 message: format!(
629 "{} dependency cycle(s) detected — review dependency graph",
630 dependencies.cycle_count
631 ),
632 impact: 10.0,
633 affected_count: dependencies.cycle_count,
634 });
635 }
636
637 let missing_licenses = total_components - licenses.with_declared;
639 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
640 {
641 recommendations.push(Recommendation {
642 priority: 3,
643 category: RecommendationCategory::Licenses,
644 message: "Add license information to components".to_string(),
645 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
646 affected_count: missing_licenses,
647 });
648 }
649
650 if licenses.noassertion_count > 0 {
652 recommendations.push(Recommendation {
653 priority: 3,
654 category: RecommendationCategory::Licenses,
655 message: "Replace NOASSERTION with actual license information".to_string(),
656 impact: 5.0,
657 affected_count: licenses.noassertion_count,
658 });
659 }
660
661 if total_components > 0 {
663 let missing_vcs = total_components
664 .saturating_sub(
665 ((completeness.components_with_hashes / 100.0) * total_components as f32)
666 as usize,
667 );
668 if missing_vcs > total_components / 2 {
669 recommendations.push(Recommendation {
670 priority: 3,
671 category: RecommendationCategory::Provenance,
672 message: "Add VCS (source repository) URLs to components".to_string(),
673 impact: 5.0,
674 affected_count: missing_vcs,
675 });
676 }
677 }
678
679 if licenses.non_standard_licenses > 0 {
681 recommendations.push(Recommendation {
682 priority: 4,
683 category: RecommendationCategory::Licenses,
684 message: "Use SPDX license identifiers for better interoperability".to_string(),
685 impact: 3.0,
686 affected_count: licenses.non_standard_licenses,
687 });
688 }
689
690 if lifecycle.outdated_components > 0 {
692 recommendations.push(Recommendation {
693 priority: 4,
694 category: RecommendationCategory::Lifecycle,
695 message: format!(
696 "{} component(s) are outdated — newer versions available",
697 lifecycle.outdated_components
698 ),
699 impact: 5.0,
700 affected_count: lifecycle.outdated_components,
701 });
702 }
703
704 if provenance.completeness_declaration == CompletenessDeclaration::Unknown
706 && matches!(
707 self.profile,
708 ScoringProfile::Cra | ScoringProfile::Comprehensive
709 )
710 {
711 recommendations.push(Recommendation {
712 priority: 4,
713 category: RecommendationCategory::Provenance,
714 message: "Add compositions section with aggregate completeness declaration"
715 .to_string(),
716 impact: 5.0,
717 affected_count: 0,
718 });
719 }
720
721 if total_components > 1 && dependencies.total_dependencies == 0 {
723 recommendations.push(Recommendation {
724 priority: 4,
725 category: RecommendationCategory::Dependencies,
726 message: "Add dependency relationships between components".to_string(),
727 impact: 10.0,
728 affected_count: total_components,
729 });
730 }
731
732 if dependencies.orphan_components > 1
734 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
735 {
736 recommendations.push(Recommendation {
737 priority: 4,
738 category: RecommendationCategory::Dependencies,
739 message: "Review orphan components that have no dependency relationships"
740 .to_string(),
741 impact: 5.0,
742 affected_count: dependencies.orphan_components,
743 });
744 }
745
746 let missing_suppliers = total_components
748 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
749 if missing_suppliers > 0
750 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
751 {
752 recommendations.push(Recommendation {
753 priority: 5,
754 category: RecommendationCategory::Completeness,
755 message: "Add supplier information to components".to_string(),
756 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
757 affected_count: missing_suppliers,
758 });
759 }
760
761 let missing_hashes = total_components
763 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
764 if missing_hashes > 0
765 && matches!(
766 self.profile,
767 ScoringProfile::Security | ScoringProfile::Comprehensive
768 )
769 {
770 recommendations.push(Recommendation {
771 priority: 5,
772 category: RecommendationCategory::Integrity,
773 message: "Add cryptographic hashes for integrity verification".to_string(),
774 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
775 affected_count: missing_hashes,
776 });
777 }
778
779 if !provenance.has_signature
781 && matches!(
782 self.profile,
783 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
784 )
785 {
786 recommendations.push(Recommendation {
787 priority: 5,
788 category: RecommendationCategory::Integrity,
789 message: "Consider adding a digital signature to the SBOM".to_string(),
790 impact: 3.0,
791 affected_count: 0,
792 });
793 }
794
795 recommendations.sort_by(|a, b| {
797 a.priority.cmp(&b.priority).then_with(|| {
798 b.impact
799 .partial_cmp(&a.impact)
800 .unwrap_or(std::cmp::Ordering::Equal)
801 })
802 });
803
804 recommendations
805 }
806}
807
808impl Default for QualityScorer {
809 fn default() -> Self {
810 Self::new(ScoringProfile::Standard)
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
819 fn test_grade_from_score() {
820 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
821 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
822 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
823 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
824 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
825 }
826
827 #[test]
828 fn test_scoring_profile_compliance_level() {
829 assert_eq!(
830 ScoringProfile::Minimal.compliance_level(),
831 ComplianceLevel::Minimum
832 );
833 assert_eq!(
834 ScoringProfile::Security.compliance_level(),
835 ComplianceLevel::NtiaMinimum
836 );
837 assert_eq!(
838 ScoringProfile::Comprehensive.compliance_level(),
839 ComplianceLevel::Comprehensive
840 );
841 }
842
843 #[test]
844 fn test_scoring_weights_sum_to_one() {
845 let profiles = [
846 ScoringProfile::Minimal,
847 ScoringProfile::Standard,
848 ScoringProfile::Security,
849 ScoringProfile::LicenseCompliance,
850 ScoringProfile::Cra,
851 ScoringProfile::Comprehensive,
852 ];
853 for profile in &profiles {
854 let w = profile.weights();
855 let sum: f32 = w.as_array().iter().sum();
856 assert!(
857 (sum - 1.0).abs() < 0.01,
858 "{profile:?} weights sum to {sum}, expected 1.0"
859 );
860 }
861 }
862
863 #[test]
864 fn test_renormalize_all_available() {
865 let w = ScoringProfile::Standard.weights();
866 let available = [true; 8];
867 let norm = w.renormalize(&available);
868 let sum: f32 = norm.iter().sum();
869 assert!((sum - 1.0).abs() < 0.001);
870 }
871
872 #[test]
873 fn test_renormalize_lifecycle_unavailable() {
874 let w = ScoringProfile::Standard.weights();
875 let mut available = [true; 8];
876 available[7] = false; let norm = w.renormalize(&available);
878 let sum: f32 = norm.iter().sum();
879 assert!((sum - 1.0).abs() < 0.001);
880 assert_eq!(norm[7], 0.0);
881 }
882
883 #[test]
884 fn test_scoring_engine_version() {
885 assert_eq!(SCORING_ENGINE_VERSION, "2.0");
886 }
887}