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::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11    AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, CryptographyMetrics,
12    DependencyMetrics, HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics,
13    ProvenanceMetrics, VulnerabilityMetrics,
14};
15
16/// Quality scoring engine version
17pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19/// Scoring profile determines weights and thresholds
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23    /// Minimal requirements - basic identification
24    Minimal,
25    /// Standard requirements - recommended for most use cases
26    Standard,
27    /// Security-focused - emphasizes vulnerability info and supply chain
28    Security,
29    /// License-focused - emphasizes license compliance
30    LicenseCompliance,
31    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
32    Cra,
33    /// BSI TR-03183-2 (German national CRA-aligned SBOM technical guideline).
34    /// Stricter than CRA on hashes and identifiers; uses CRA-style weights.
35    BsiTr03183_2,
36    /// Comprehensive - all aspects equally weighted
37    Comprehensive,
38    /// CBOM - cryptographic BOM focus (algorithm strength, PQC readiness, key/cert lifecycle)
39    Cbom,
40}
41
42impl ScoringProfile {
43    /// Get the compliance level associated with this profile
44    #[must_use]
45    pub const fn compliance_level(&self) -> ComplianceLevel {
46        match self {
47            Self::Minimal => ComplianceLevel::Minimum,
48            Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
49            Self::Security => ComplianceLevel::NtiaMinimum,
50            Self::Cra => ComplianceLevel::CraPhase2,
51            Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
52            Self::Comprehensive => ComplianceLevel::Comprehensive,
53            Self::Cbom => ComplianceLevel::Comprehensive,
54        }
55    }
56
57    /// Get weights for this profile
58    ///
59    /// All weights sum to 1.0. The lifecycle weight is applied only when
60    /// enrichment data is available; otherwise it is redistributed.
61    const fn weights(self) -> ScoringWeights {
62        match self {
63            Self::Minimal => ScoringWeights {
64                completeness: 0.35,
65                identifiers: 0.20,
66                licenses: 0.10,
67                vulnerabilities: 0.05,
68                dependencies: 0.10,
69                integrity: 0.05,
70                provenance: 0.10,
71                lifecycle: 0.05,
72            },
73            Self::Standard => ScoringWeights {
74                completeness: 0.25,
75                identifiers: 0.20,
76                licenses: 0.12,
77                vulnerabilities: 0.08,
78                dependencies: 0.10,
79                integrity: 0.08,
80                provenance: 0.10,
81                lifecycle: 0.07,
82            },
83            Self::Security => ScoringWeights {
84                completeness: 0.12,
85                identifiers: 0.18,
86                licenses: 0.05,
87                vulnerabilities: 0.20,
88                dependencies: 0.10,
89                integrity: 0.15,
90                provenance: 0.10,
91                lifecycle: 0.10,
92            },
93            Self::LicenseCompliance => ScoringWeights {
94                completeness: 0.15,
95                identifiers: 0.12,
96                licenses: 0.35,
97                vulnerabilities: 0.05,
98                dependencies: 0.10,
99                integrity: 0.05,
100                provenance: 0.10,
101                lifecycle: 0.08,
102            },
103            Self::Cra => ScoringWeights {
104                completeness: 0.12,
105                identifiers: 0.18,
106                licenses: 0.08,
107                vulnerabilities: 0.15,
108                dependencies: 0.12,
109                integrity: 0.12,
110                provenance: 0.15,
111                lifecycle: 0.08,
112            },
113            // BSI TR-03183-2 emphasises identifiers and integrity (mandatory hashes)
114            // even more than CRA, while still tracking provenance/dependencies.
115            Self::BsiTr03183_2 => ScoringWeights {
116                completeness: 0.10,
117                identifiers: 0.22,
118                licenses: 0.08,
119                vulnerabilities: 0.10,
120                dependencies: 0.12,
121                integrity: 0.18,
122                provenance: 0.12,
123                lifecycle: 0.08,
124            },
125            Self::Comprehensive => ScoringWeights {
126                completeness: 0.15,
127                identifiers: 0.13,
128                licenses: 0.13,
129                vulnerabilities: 0.10,
130                dependencies: 0.12,
131                integrity: 0.12,
132                provenance: 0.13,
133                lifecycle: 0.12,
134            },
135            // CBOM slots are reinterpreted:
136            // completeness->CryptoCompl, identifiers->OIDs, licenses->AlgoStrength,
137            // vulnerabilities->CryptoRefs, dependencies->CryptoLifecycle,
138            // integrity->PQCReadiness, provenance->Provenance(std), lifecycle->Licenses(std)
139            Self::Cbom => ScoringWeights {
140                completeness: 0.15,
141                identifiers: 0.15,
142                licenses: 0.22,
143                vulnerabilities: 0.10,
144                dependencies: 0.13,
145                integrity: 0.15,
146                provenance: 0.08,
147                lifecycle: 0.02,
148            },
149        }
150    }
151}
152
153/// Weights for overall score calculation (sum to 1.0)
154#[derive(Debug, Clone)]
155struct ScoringWeights {
156    completeness: f32,
157    identifiers: f32,
158    licenses: f32,
159    vulnerabilities: f32,
160    dependencies: f32,
161    integrity: f32,
162    provenance: f32,
163    lifecycle: f32,
164}
165
166impl ScoringWeights {
167    /// Return weights as an array for iteration
168    fn as_array(&self) -> [f32; 8] {
169        [
170            self.completeness,
171            self.identifiers,
172            self.licenses,
173            self.vulnerabilities,
174            self.dependencies,
175            self.integrity,
176            self.provenance,
177            self.lifecycle,
178        ]
179    }
180
181    /// Renormalize weights, excluding categories marked as N/A.
182    ///
183    /// When a category has no applicable data (e.g., lifecycle without
184    /// enrichment), its weight is proportionally redistributed.
185    fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
186        let raw = self.as_array();
187        let total_available: f32 = raw
188            .iter()
189            .zip(available)
190            .filter(|&(_, a)| *a)
191            .map(|(w, _)| w)
192            .sum();
193
194        if total_available <= 0.0 {
195            return [0.0; 8];
196        }
197
198        let scale = 1.0 / total_available;
199        let mut result = [0.0_f32; 8];
200        for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
201            result[i] = if avail { w * scale } else { 0.0 };
202        }
203        result
204    }
205}
206
207/// Quality grade based on score
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[non_exhaustive]
210pub enum QualityGrade {
211    /// Excellent: 90-100
212    A,
213    /// Good: 80-89
214    B,
215    /// Fair: 70-79
216    C,
217    /// Poor: 60-69
218    D,
219    /// Failing: <60
220    F,
221}
222
223impl QualityGrade {
224    /// Create grade from score
225    #[must_use]
226    pub const fn from_score(score: f32) -> Self {
227        // Guard against NaN (all comparisons return false) and out-of-range values
228        let clamped = if score > 100.0 {
229            100
230        } else if score >= 0.0 {
231            score as u32
232        } else {
233            0
234        };
235        match clamped {
236            90..=100 => Self::A,
237            80..=89 => Self::B,
238            70..=79 => Self::C,
239            60..=69 => Self::D,
240            _ => Self::F,
241        }
242    }
243
244    /// Get grade letter
245    #[must_use]
246    pub const fn letter(&self) -> &'static str {
247        match self {
248            Self::A => "A",
249            Self::B => "B",
250            Self::C => "C",
251            Self::D => "D",
252            Self::F => "F",
253        }
254    }
255
256    /// Get grade description
257    #[must_use]
258    pub const fn description(&self) -> &'static str {
259        match self {
260            Self::A => "Excellent",
261            Self::B => "Good",
262            Self::C => "Fair",
263            Self::D => "Poor",
264            Self::F => "Failing",
265        }
266    }
267}
268
269/// Recommendation for improving quality
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct Recommendation {
272    /// Priority (1 = highest, 5 = lowest)
273    pub priority: u8,
274    /// Category of the recommendation
275    pub category: RecommendationCategory,
276    /// Human-readable message
277    pub message: String,
278    /// Estimated impact on score (0-100)
279    pub impact: f32,
280    /// Affected components (if applicable)
281    pub affected_count: usize,
282}
283
284/// Category for recommendations
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[non_exhaustive]
287pub enum RecommendationCategory {
288    Completeness,
289    Identifiers,
290    Licenses,
291    Vulnerabilities,
292    Dependencies,
293    Compliance,
294    Integrity,
295    Provenance,
296    Lifecycle,
297}
298
299impl RecommendationCategory {
300    #[must_use]
301    pub const fn name(&self) -> &'static str {
302        match self {
303            Self::Completeness => "Completeness",
304            Self::Identifiers => "Identifiers",
305            Self::Licenses => "Licenses",
306            Self::Vulnerabilities => "Vulnerabilities",
307            Self::Dependencies => "Dependencies",
308            Self::Compliance => "Compliance",
309            Self::Integrity => "Integrity",
310            Self::Provenance => "Provenance",
311            Self::Lifecycle => "Lifecycle",
312        }
313    }
314}
315
316/// Complete quality report for an SBOM
317#[derive(Debug, Clone, Serialize, Deserialize)]
318#[must_use]
319pub struct QualityReport {
320    /// Scoring engine version
321    pub scoring_engine_version: String,
322    /// Overall score (0-100)
323    pub overall_score: f32,
324    /// Overall grade
325    pub grade: QualityGrade,
326    /// Scoring profile used
327    pub profile: ScoringProfile,
328
329    // Individual category scores (0-100)
330    /// Completeness score
331    pub completeness_score: f32,
332    /// Identifier quality score
333    pub identifier_score: f32,
334    /// License quality score
335    pub license_score: f32,
336    /// Vulnerability documentation score (`None` if no vulnerability data)
337    pub vulnerability_score: Option<f32>,
338    /// Dependency graph quality score
339    pub dependency_score: f32,
340    /// Hash/integrity quality score
341    pub integrity_score: f32,
342    /// Provenance quality score (combined provenance + auditability)
343    pub provenance_score: f32,
344    /// Lifecycle quality score (`None` if no enrichment data)
345    pub lifecycle_score: Option<f32>,
346
347    // Detailed metrics
348    /// Detailed completeness metrics
349    pub completeness_metrics: CompletenessMetrics,
350    /// Detailed identifier metrics
351    pub identifier_metrics: IdentifierMetrics,
352    /// Detailed license metrics
353    pub license_metrics: LicenseMetrics,
354    /// Detailed vulnerability metrics
355    pub vulnerability_metrics: VulnerabilityMetrics,
356    /// Detailed dependency metrics
357    pub dependency_metrics: DependencyMetrics,
358    /// Hash/integrity metrics
359    pub hash_quality_metrics: HashQualityMetrics,
360    /// Provenance metrics
361    pub provenance_metrics: ProvenanceMetrics,
362    /// Auditability metrics
363    pub auditability_metrics: AuditabilityMetrics,
364    /// Lifecycle metrics (enrichment-dependent)
365    pub lifecycle_metrics: LifecycleMetrics,
366    /// Cryptography quality score (`None` if no crypto components)
367    pub cryptography_score: Option<f32>,
368    /// Cryptography metrics (CBOM)
369    pub cryptography_metrics: CryptographyMetrics,
370
371    /// Compliance check result
372    pub compliance: ComplianceResult,
373    /// Prioritized recommendations
374    pub recommendations: Vec<Recommendation>,
375}
376
377/// Quality scorer for SBOMs
378#[derive(Debug, Clone)]
379pub struct QualityScorer {
380    /// Scoring profile
381    profile: ScoringProfile,
382    /// Completeness weights
383    completeness_weights: CompletenessWeights,
384    /// Optional CRA sidecar metadata; when set, the embedded compliance check
385    /// (used to drive recommendations under `ScoringProfile::Cra`) consults
386    /// the sidecar for fields the SBOM doesn't carry.
387    cra_sidecar: Option<crate::model::CraSidecarMetadata>,
388    /// Optional CRA Annex III/IV product class. Sidecar `productClass` (when
389    /// present on `cra_sidecar`) wins over this value at check time.
390    cra_product_class: Option<crate::model::CraProductClass>,
391}
392
393impl QualityScorer {
394    /// Create a new quality scorer with the given profile
395    #[must_use]
396    pub fn new(profile: ScoringProfile) -> Self {
397        Self {
398            profile,
399            completeness_weights: CompletenessWeights::default(),
400            cra_sidecar: None,
401            cra_product_class: None,
402        }
403    }
404
405    /// Set custom completeness weights
406    #[must_use]
407    pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
408        self.completeness_weights = weights;
409        self
410    }
411
412    /// Attach CRA sidecar metadata for the embedded compliance check.
413    #[must_use]
414    pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
415        self.cra_sidecar = Some(sidecar);
416        self
417    }
418
419    /// Set the CRA Annex III/IV product class explicitly (for severity
420    /// calibration when the embedded compliance check runs under
421    /// `ScoringProfile::Cra`). Sidecar `productClass` overrides this.
422    #[must_use]
423    pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
424        self.cra_product_class = Some(class);
425        self
426    }
427
428    /// Score an SBOM
429    pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
430        let total_components = sbom.components.len();
431        let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
432
433        // Calculate all metrics
434        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
435        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
436        let license_metrics = LicenseMetrics::from_sbom(sbom);
437        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
438        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
439        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
440        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
441        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
442        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
443        let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
444
445        // Calculate individual category scores
446        let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
447        let identifier_score = identifier_metrics.quality_score(total_components);
448        let license_score = license_metrics.quality_score(total_components);
449        let vulnerability_score = vulnerability_metrics.documentation_score();
450        let dependency_score = dependency_metrics.quality_score(total_components);
451        let integrity_score = hash_quality_metrics.quality_score(total_components);
452        let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
453        let auditability_raw = auditability_metrics.quality_score(total_components);
454        // Combine provenance and auditability (60/40 split)
455        let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
456        let lifecycle_score = lifecycle_metrics.quality_score();
457        let cryptography_score = cryptography_metrics.quality_score();
458
459        // For CBOM profile, substitute crypto-specific scores into the 8 slots
460        let is_cbom = self.profile == ScoringProfile::Cbom;
461        let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
462            let cm = &cryptography_metrics;
463            (
464                [true; 8], // all categories available for CBOM
465                [
466                    cm.crypto_completeness_score(), // slot 1: Crpt
467                    cm.crypto_identifier_score(),   // slot 2: OIDs
468                    cm.algorithm_strength_score(),  // slot 3: Algo
469                    cm.crypto_dependency_score(),   // slot 4: Refs
470                    cm.crypto_lifecycle_score(),    // slot 5: Life
471                    cm.pqc_readiness_score(),       // slot 6: PQC
472                    provenance_score,               // slot 7: Prov (standard)
473                    license_score,                  // slot 8: Lic  (standard)
474                ],
475            )
476        } else {
477            // Standard SBOM scoring
478            let vuln_available = vulnerability_score.is_some();
479            let lifecycle_available = lifecycle_score.is_some();
480            (
481                [
482                    true,                // completeness
483                    true,                // identifiers
484                    true,                // licenses
485                    vuln_available,      // vulnerabilities
486                    true,                // dependencies
487                    true,                // integrity
488                    true,                // provenance
489                    lifecycle_available, // lifecycle
490                ],
491                [
492                    completeness_score,
493                    identifier_score,
494                    license_score,
495                    vulnerability_score.unwrap_or(0.0),
496                    dependency_score,
497                    integrity_score,
498                    provenance_score,
499                    lifecycle_score.unwrap_or(0.0),
500                ],
501            )
502        };
503
504        // Calculate weighted overall score with N/A renormalization
505        let weights = self.profile.weights();
506        let norm = weights.renormalize(&available);
507
508        let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
509        overall_score = overall_score.min(100.0);
510
511        // Apply hard penalty caps for critical issues
512        overall_score = self.apply_score_caps(
513            overall_score,
514            &lifecycle_metrics,
515            &dependency_metrics,
516            &hash_quality_metrics,
517            &cryptography_metrics,
518            total_components,
519        );
520
521        // Run compliance check (with sidecar + product class if configured)
522        let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
523        if let Some(sc) = self.cra_sidecar.clone() {
524            compliance_checker = compliance_checker.with_sidecar(sc);
525        }
526        if let Some(c) = self.cra_product_class {
527            compliance_checker = compliance_checker.with_product_class(c);
528        }
529        let compliance = compliance_checker.check(sbom);
530
531        // Generate recommendations
532        let recommendations = self.generate_recommendations(
533            &completeness_metrics,
534            &identifier_metrics,
535            &license_metrics,
536            &dependency_metrics,
537            &hash_quality_metrics,
538            &provenance_metrics,
539            &lifecycle_metrics,
540            &compliance,
541            total_components,
542        );
543
544        QualityReport {
545            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
546            overall_score,
547            grade: QualityGrade::from_score(overall_score),
548            profile: self.profile,
549            completeness_score,
550            identifier_score,
551            license_score,
552            vulnerability_score,
553            dependency_score,
554            integrity_score,
555            provenance_score,
556            lifecycle_score,
557            completeness_metrics,
558            identifier_metrics,
559            license_metrics,
560            vulnerability_metrics,
561            dependency_metrics,
562            hash_quality_metrics,
563            provenance_metrics,
564            auditability_metrics,
565            lifecycle_metrics,
566            cryptography_score,
567            cryptography_metrics,
568            compliance,
569            recommendations,
570        }
571    }
572
573    /// Apply hard score caps for critical issues
574    fn apply_score_caps(
575        &self,
576        mut score: f32,
577        lifecycle: &LifecycleMetrics,
578        deps: &DependencyMetrics,
579        hashes: &HashQualityMetrics,
580        crypto: &CryptographyMetrics,
581        total_components: usize,
582    ) -> f32 {
583        let is_security_profile =
584            matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
585
586        // EOL components: cap at D grade for security-focused profiles
587        if is_security_profile && lifecycle.eol_components > 0 {
588            score = score.min(69.0);
589        }
590
591        // Dependency cycles: cap at B grade
592        if deps.cycle_count > 0
593            && matches!(
594                self.profile,
595                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
596            )
597        {
598            score = score.min(89.0);
599        }
600
601        // No hashes at all: cap at C grade for Security profile
602        if matches!(self.profile, ScoringProfile::Security)
603            && total_components > 0
604            && hashes.components_with_any_hash == 0
605        {
606            score = score.min(79.0);
607        }
608
609        // Weak-only hashes: cap at B grade for Security profile
610        if matches!(self.profile, ScoringProfile::Security)
611            && hashes.components_with_weak_only > 0
612            && hashes.components_with_strong_hash == 0
613        {
614            score = score.min(89.0);
615        }
616
617        // CBOM-specific hard caps
618        if self.profile == ScoringProfile::Cbom && crypto.has_data() {
619            if crypto.weak_algorithm_count > 0 {
620                score = score.min(69.0);
621            }
622            if crypto.compromised_keys > 0 {
623                score = score.min(79.0);
624            }
625            if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
626                score = score.min(79.0);
627            }
628        }
629
630        score
631    }
632
633    #[allow(clippy::too_many_arguments)]
634    fn generate_recommendations(
635        &self,
636        completeness: &CompletenessMetrics,
637        identifiers: &IdentifierMetrics,
638        licenses: &LicenseMetrics,
639        dependencies: &DependencyMetrics,
640        hashes: &HashQualityMetrics,
641        provenance: &ProvenanceMetrics,
642        lifecycle: &LifecycleMetrics,
643        compliance: &ComplianceResult,
644        total_components: usize,
645    ) -> Vec<Recommendation> {
646        let mut recommendations = Vec::new();
647
648        // Priority 1: Compliance errors
649        if compliance.error_count > 0 {
650            recommendations.push(Recommendation {
651                priority: 1,
652                category: RecommendationCategory::Compliance,
653                message: format!(
654                    "Fix {} compliance error(s) to meet {} requirements",
655                    compliance.error_count,
656                    compliance.level.name()
657                ),
658                impact: 20.0,
659                affected_count: compliance.error_count,
660            });
661        }
662
663        // Priority 1: EOL components
664        if lifecycle.eol_components > 0 {
665            recommendations.push(Recommendation {
666                priority: 1,
667                category: RecommendationCategory::Lifecycle,
668                message: format!(
669                    "{} component(s) have reached end-of-life — upgrade or replace",
670                    lifecycle.eol_components
671                ),
672                impact: 15.0,
673                affected_count: lifecycle.eol_components,
674            });
675        }
676
677        // Priority 1: Missing versions (critical for identification)
678        let missing_versions = total_components
679            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
680        if missing_versions > 0 {
681            recommendations.push(Recommendation {
682                priority: 1,
683                category: RecommendationCategory::Completeness,
684                message: "Add version information to all components".to_string(),
685                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
686                affected_count: missing_versions,
687            });
688        }
689
690        // Priority 2: Weak-only hashes
691        if hashes.components_with_weak_only > 0 {
692            recommendations.push(Recommendation {
693                priority: 2,
694                category: RecommendationCategory::Integrity,
695                message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
696                impact: 10.0,
697                affected_count: hashes.components_with_weak_only,
698            });
699        }
700
701        // Priority 2: Missing PURLs (important for identification)
702        if identifiers.missing_all_identifiers > 0 {
703            recommendations.push(Recommendation {
704                priority: 2,
705                category: RecommendationCategory::Identifiers,
706                message: "Add PURL or CPE identifiers to components".to_string(),
707                impact: (identifiers.missing_all_identifiers as f32
708                    / total_components.max(1) as f32)
709                    * 20.0,
710                affected_count: identifiers.missing_all_identifiers,
711            });
712        }
713
714        // Priority 2: Invalid identifiers
715        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
716        if invalid_ids > 0 {
717            recommendations.push(Recommendation {
718                priority: 2,
719                category: RecommendationCategory::Identifiers,
720                message: "Fix malformed PURL/CPE identifiers".to_string(),
721                impact: 10.0,
722                affected_count: invalid_ids,
723            });
724        }
725
726        // Priority 2: Missing tool creator info
727        if !provenance.has_tool_creator {
728            recommendations.push(Recommendation {
729                priority: 2,
730                category: RecommendationCategory::Provenance,
731                message: "Add SBOM creation tool information".to_string(),
732                impact: 8.0,
733                affected_count: 0,
734            });
735        }
736
737        // Priority 3: Dependency cycles
738        if dependencies.cycle_count > 0 {
739            recommendations.push(Recommendation {
740                priority: 3,
741                category: RecommendationCategory::Dependencies,
742                message: format!(
743                    "{} dependency cycle(s) detected — review dependency graph",
744                    dependencies.cycle_count
745                ),
746                impact: 10.0,
747                affected_count: dependencies.cycle_count,
748            });
749        }
750
751        // Priority 2-3: Software complexity
752        if let Some(level) = &dependencies.complexity_level {
753            match level {
754                super::metrics::ComplexityLevel::VeryHigh => {
755                    recommendations.push(Recommendation {
756                        priority: 2,
757                        category: RecommendationCategory::Dependencies,
758                        message:
759                            "Dependency structure is very complex — review for unnecessary transitive dependencies"
760                                .to_string(),
761                        impact: 8.0,
762                        affected_count: dependencies.total_dependencies,
763                    });
764                }
765                super::metrics::ComplexityLevel::High => {
766                    recommendations.push(Recommendation {
767                        priority: 3,
768                        category: RecommendationCategory::Dependencies,
769                        message:
770                            "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
771                                .to_string(),
772                        impact: 5.0,
773                        affected_count: dependencies.total_dependencies,
774                    });
775                }
776                _ => {}
777            }
778        }
779
780        // Priority 3: Missing licenses
781        let missing_licenses = total_components - licenses.with_declared;
782        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
783        {
784            recommendations.push(Recommendation {
785                priority: 3,
786                category: RecommendationCategory::Licenses,
787                message: "Add license information to components".to_string(),
788                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
789                affected_count: missing_licenses,
790            });
791        }
792
793        // Priority 3: NOASSERTION licenses
794        if licenses.noassertion_count > 0 {
795            recommendations.push(Recommendation {
796                priority: 3,
797                category: RecommendationCategory::Licenses,
798                message: "Replace NOASSERTION with actual license information".to_string(),
799                impact: 5.0,
800                affected_count: licenses.noassertion_count,
801            });
802        }
803
804        // Priority 3: VCS URL coverage
805        if total_components > 0 {
806            let missing_vcs = total_components.saturating_sub(
807                ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
808            );
809            if missing_vcs > total_components / 2 {
810                recommendations.push(Recommendation {
811                    priority: 3,
812                    category: RecommendationCategory::Provenance,
813                    message: "Add VCS (source repository) URLs to components".to_string(),
814                    impact: 5.0,
815                    affected_count: missing_vcs,
816                });
817            }
818        }
819
820        // Priority 4: Non-standard licenses
821        if licenses.non_standard_licenses > 0 {
822            recommendations.push(Recommendation {
823                priority: 4,
824                category: RecommendationCategory::Licenses,
825                message: "Use SPDX license identifiers for better interoperability".to_string(),
826                impact: 3.0,
827                affected_count: licenses.non_standard_licenses,
828            });
829        }
830
831        // Priority 4: Outdated components
832        if lifecycle.outdated_components > 0 {
833            recommendations.push(Recommendation {
834                priority: 4,
835                category: RecommendationCategory::Lifecycle,
836                message: format!(
837                    "{} component(s) are outdated — newer versions available",
838                    lifecycle.outdated_components
839                ),
840                impact: 5.0,
841                affected_count: lifecycle.outdated_components,
842            });
843        }
844
845        // Priority 4: Missing completeness declaration
846        if provenance.completeness_declaration == CompletenessDeclaration::Unknown
847            && matches!(
848                self.profile,
849                ScoringProfile::Cra | ScoringProfile::Comprehensive
850            )
851        {
852            recommendations.push(Recommendation {
853                priority: 4,
854                category: RecommendationCategory::Provenance,
855                message: "Add compositions section with aggregate completeness declaration"
856                    .to_string(),
857                impact: 5.0,
858                affected_count: 0,
859            });
860        }
861
862        // Priority 4: Missing dependency information
863        if total_components > 1 && dependencies.total_dependencies == 0 {
864            recommendations.push(Recommendation {
865                priority: 4,
866                category: RecommendationCategory::Dependencies,
867                message: "Add dependency relationships between components".to_string(),
868                impact: 10.0,
869                affected_count: total_components,
870            });
871        }
872
873        // Priority 4: Many orphan components
874        if dependencies.orphan_components > 1
875            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
876        {
877            recommendations.push(Recommendation {
878                priority: 4,
879                category: RecommendationCategory::Dependencies,
880                message: "Review orphan components that have no dependency relationships"
881                    .to_string(),
882                impact: 5.0,
883                affected_count: dependencies.orphan_components,
884            });
885        }
886
887        // Priority 5: Missing supplier information
888        let missing_suppliers = total_components
889            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
890        if missing_suppliers > 0
891            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
892        {
893            recommendations.push(Recommendation {
894                priority: 5,
895                category: RecommendationCategory::Completeness,
896                message: "Add supplier information to components".to_string(),
897                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
898                affected_count: missing_suppliers,
899            });
900        }
901
902        // Priority 5: Missing hashes
903        let missing_hashes = total_components
904            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
905        if missing_hashes > 0
906            && matches!(
907                self.profile,
908                ScoringProfile::Security | ScoringProfile::Comprehensive
909            )
910        {
911            recommendations.push(Recommendation {
912                priority: 5,
913                category: RecommendationCategory::Integrity,
914                message: "Add cryptographic hashes for integrity verification".to_string(),
915                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
916                affected_count: missing_hashes,
917            });
918        }
919
920        // Priority 5: Consider SBOM signing (only if not already signed)
921        if !provenance.has_signature
922            && matches!(
923                self.profile,
924                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
925            )
926        {
927            recommendations.push(Recommendation {
928                priority: 5,
929                category: RecommendationCategory::Integrity,
930                message: "Consider adding a digital signature to the SBOM".to_string(),
931                impact: 3.0,
932                affected_count: 0,
933            });
934        }
935
936        // Sort by priority, then by impact
937        recommendations.sort_by(|a, b| {
938            a.priority.cmp(&b.priority).then_with(|| {
939                b.impact
940                    .partial_cmp(&a.impact)
941                    .unwrap_or(std::cmp::Ordering::Equal)
942            })
943        });
944
945        recommendations
946    }
947}
948
949impl Default for QualityScorer {
950    fn default() -> Self {
951        Self::new(ScoringProfile::Standard)
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn test_grade_from_score() {
961        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
962        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
963        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
964        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
965        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
966    }
967
968    #[test]
969    fn test_scoring_profile_compliance_level() {
970        assert_eq!(
971            ScoringProfile::Minimal.compliance_level(),
972            ComplianceLevel::Minimum
973        );
974        assert_eq!(
975            ScoringProfile::Security.compliance_level(),
976            ComplianceLevel::NtiaMinimum
977        );
978        assert_eq!(
979            ScoringProfile::Comprehensive.compliance_level(),
980            ComplianceLevel::Comprehensive
981        );
982    }
983
984    #[test]
985    fn test_scoring_weights_sum_to_one() {
986        let profiles = [
987            ScoringProfile::Minimal,
988            ScoringProfile::Standard,
989            ScoringProfile::Security,
990            ScoringProfile::LicenseCompliance,
991            ScoringProfile::Cra,
992            ScoringProfile::Comprehensive,
993            ScoringProfile::Cbom,
994        ];
995        for profile in &profiles {
996            let w = profile.weights();
997            let sum: f32 = w.as_array().iter().sum();
998            assert!(
999                (sum - 1.0).abs() < 0.01,
1000                "{profile:?} weights sum to {sum}, expected 1.0"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn test_renormalize_all_available() {
1007        let w = ScoringProfile::Standard.weights();
1008        let available = [true; 8];
1009        let norm = w.renormalize(&available);
1010        let sum: f32 = norm.iter().sum();
1011        assert!((sum - 1.0).abs() < 0.001);
1012    }
1013
1014    #[test]
1015    fn test_renormalize_lifecycle_unavailable() {
1016        let w = ScoringProfile::Standard.weights();
1017        let mut available = [true; 8];
1018        available[7] = false; // lifecycle
1019        let norm = w.renormalize(&available);
1020        let sum: f32 = norm.iter().sum();
1021        assert!((sum - 1.0).abs() < 0.001);
1022        assert_eq!(norm[7], 0.0);
1023    }
1024
1025    #[test]
1026    fn test_scoring_engine_version() {
1027        assert_eq!(SCORING_ENGINE_VERSION, "2.0");
1028    }
1029
1030    #[test]
1031    fn cbom_hard_cap_weak_algorithms() {
1032        use crate::model::{
1033            AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
1034            CryptoPrimitive, CryptoProperties, NormalizedSbom,
1035        };
1036
1037        let mut sbom = NormalizedSbom::default();
1038        // Add a weak crypto component (MD5 algorithm)
1039        let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
1040        comp.component_type = ComponentType::Cryptographic;
1041        comp.crypto_properties = Some(
1042            CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1043                AlgorithmProperties::new(CryptoPrimitive::Hash)
1044                    .with_algorithm_family("MD5".to_string())
1045                    .with_nist_quantum_security_level(0),
1046            ),
1047        );
1048        sbom.components
1049            .insert(CanonicalId::from_name_version("md5", None), comp);
1050
1051        let scorer = QualityScorer::new(ScoringProfile::Cbom);
1052        let report = scorer.score(&sbom);
1053        // Weak algorithm → D max (69)
1054        assert!(
1055            report.overall_score <= 69.0,
1056            "weak algo should cap at D, got {}",
1057            report.overall_score
1058        );
1059    }
1060}