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};
8use serde_json::Value;
9
10use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
11use super::metrics::{
12    AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, CryptographyMetrics,
13    DependencyMetrics, HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics,
14    ProvenanceMetrics, VulnerabilityMetrics,
15};
16
17/// Quality scoring engine version
18pub const SCORING_ENGINE_VERSION: &str = "2.1";
19
20/// Returns true if any of the JSON pointers resolves to a non-empty value in `raw`.
21/// Used by the AI-readiness profile to inspect model-card fields preserved in
22/// `Component.extensions.raw` that are not surfaced into the typed model.
23fn has_non_empty_pointer(raw: Option<&Value>, pointers: &[&str]) -> bool {
24    pointers
25        .iter()
26        .filter_map(|pointer| raw.and_then(|value| value.pointer(pointer)))
27        .any(|value| match value {
28            Value::Null => false,
29            Value::Array(items) => !items.is_empty(),
30            Value::Object(entries) => !entries.is_empty(),
31            Value::String(text) => !text.trim().is_empty(),
32            _ => true,
33        })
34}
35
36/// Returns true if a component is connected to the vulnerability/exploitability
37/// tooling stack: it carries at least one vulnerability reference (which
38/// OSV/KEV/EPSS/VEX enrichment acts on) OR a security/advisory external
39/// reference an analyst can pivot on. This realizes the BSI thesis that an AI
40/// SBOM is only useful when linked to cybersecurity tooling.
41fn ml_has_exploitability_reference(component: &crate::model::Component) -> bool {
42    use crate::model::ExternalRefType;
43    if !component.vulnerabilities.is_empty() {
44        return true;
45    }
46    component.external_refs.iter().any(|r| {
47        matches!(
48            r.ref_type,
49            ExternalRefType::Advisories
50                | ExternalRefType::SecurityContact
51                | ExternalRefType::VulnerabilityAssertion
52                | ExternalRefType::ExploitabilityStatement
53        )
54    })
55}
56
57/// Scoring profile determines weights and thresholds
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[non_exhaustive]
60pub enum ScoringProfile {
61    /// Minimal requirements - basic identification
62    Minimal,
63    /// Standard requirements - recommended for most use cases
64    Standard,
65    /// Security-focused - emphasizes vulnerability info and supply chain
66    Security,
67    /// License-focused - emphasizes license compliance
68    LicenseCompliance,
69    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
70    Cra,
71    /// BSI TR-03183-2 (German national CRA-aligned SBOM technical guideline).
72    /// Stricter than CRA on hashes and identifiers; uses CRA-style weights.
73    BsiTr03183_2,
74    /// Comprehensive - all aspects equally weighted
75    Comprehensive,
76    /// CBOM - cryptographic BOM focus (algorithm strength, PQC readiness, key/cert lifecycle)
77    Cbom,
78    /// AI/ML readiness - evaluates model-card completeness for machine-learning components
79    AiReadiness,
80}
81
82impl ScoringProfile {
83    /// Get the compliance level associated with this profile
84    #[must_use]
85    pub const fn compliance_level(&self) -> ComplianceLevel {
86        match self {
87            Self::Minimal => ComplianceLevel::Minimum,
88            Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
89            Self::Security => ComplianceLevel::NtiaMinimum,
90            Self::Cra => ComplianceLevel::CraPhase2,
91            Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
92            Self::Comprehensive => ComplianceLevel::Comprehensive,
93            Self::Cbom => ComplianceLevel::Comprehensive,
94            Self::AiReadiness => ComplianceLevel::Comprehensive,
95        }
96    }
97
98    /// Get weights for this profile
99    ///
100    /// All weights sum to 1.0. The lifecycle weight is applied only when
101    /// enrichment data is available; otherwise it is redistributed.
102    const fn weights(self) -> ScoringWeights {
103        match self {
104            Self::Minimal => ScoringWeights {
105                completeness: 0.35,
106                identifiers: 0.20,
107                licenses: 0.10,
108                vulnerabilities: 0.05,
109                dependencies: 0.10,
110                integrity: 0.05,
111                provenance: 0.10,
112                lifecycle: 0.05,
113            },
114            Self::Standard => ScoringWeights {
115                completeness: 0.25,
116                identifiers: 0.20,
117                licenses: 0.12,
118                vulnerabilities: 0.08,
119                dependencies: 0.10,
120                integrity: 0.08,
121                provenance: 0.10,
122                lifecycle: 0.07,
123            },
124            Self::Security => ScoringWeights {
125                completeness: 0.12,
126                identifiers: 0.18,
127                licenses: 0.05,
128                vulnerabilities: 0.20,
129                dependencies: 0.10,
130                integrity: 0.15,
131                provenance: 0.10,
132                lifecycle: 0.10,
133            },
134            Self::LicenseCompliance => ScoringWeights {
135                completeness: 0.15,
136                identifiers: 0.12,
137                licenses: 0.35,
138                vulnerabilities: 0.05,
139                dependencies: 0.10,
140                integrity: 0.05,
141                provenance: 0.10,
142                lifecycle: 0.08,
143            },
144            Self::Cra => ScoringWeights {
145                completeness: 0.12,
146                identifiers: 0.18,
147                licenses: 0.08,
148                vulnerabilities: 0.15,
149                dependencies: 0.12,
150                integrity: 0.12,
151                provenance: 0.15,
152                lifecycle: 0.08,
153            },
154            // BSI TR-03183-2 emphasises identifiers and integrity (mandatory hashes)
155            // even more than CRA, while still tracking provenance/dependencies.
156            Self::BsiTr03183_2 => ScoringWeights {
157                completeness: 0.10,
158                identifiers: 0.22,
159                licenses: 0.08,
160                vulnerabilities: 0.10,
161                dependencies: 0.12,
162                integrity: 0.18,
163                provenance: 0.12,
164                lifecycle: 0.08,
165            },
166            Self::Comprehensive => ScoringWeights {
167                completeness: 0.15,
168                identifiers: 0.13,
169                licenses: 0.13,
170                vulnerabilities: 0.10,
171                dependencies: 0.12,
172                integrity: 0.12,
173                provenance: 0.13,
174                lifecycle: 0.12,
175            },
176            // CBOM slots are reinterpreted:
177            // completeness->CryptoCompl, identifiers->OIDs, licenses->AlgoStrength,
178            // vulnerabilities->CryptoRefs, dependencies->CryptoLifecycle,
179            // integrity->PQCReadiness, provenance->Provenance(std), lifecycle->Licenses(std)
180            Self::Cbom => ScoringWeights {
181                completeness: 0.15,
182                identifiers: 0.15,
183                licenses: 0.22,
184                vulnerabilities: 0.10,
185                dependencies: 0.13,
186                integrity: 0.15,
187                provenance: 0.08,
188                lifecycle: 0.02,
189            },
190            // AiReadiness uses a dedicated scoring path; these weights are only a
191            // structural fallback and are never reached in normal execution.
192            Self::AiReadiness => ScoringWeights {
193                completeness: 0.25,
194                identifiers: 0.15,
195                licenses: 0.15,
196                vulnerabilities: 0.10,
197                dependencies: 0.10,
198                integrity: 0.08,
199                provenance: 0.10,
200                lifecycle: 0.07,
201            },
202        }
203    }
204}
205
206/// Weights for overall score calculation (sum to 1.0)
207#[derive(Debug, Clone)]
208struct ScoringWeights {
209    completeness: f32,
210    identifiers: f32,
211    licenses: f32,
212    vulnerabilities: f32,
213    dependencies: f32,
214    integrity: f32,
215    provenance: f32,
216    lifecycle: f32,
217}
218
219impl ScoringWeights {
220    /// Return weights as an array for iteration
221    fn as_array(&self) -> [f32; 8] {
222        [
223            self.completeness,
224            self.identifiers,
225            self.licenses,
226            self.vulnerabilities,
227            self.dependencies,
228            self.integrity,
229            self.provenance,
230            self.lifecycle,
231        ]
232    }
233
234    /// Renormalize weights, excluding categories marked as N/A.
235    ///
236    /// When a category has no applicable data (e.g., lifecycle without
237    /// enrichment), its weight is proportionally redistributed.
238    fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
239        let raw = self.as_array();
240        let total_available: f32 = raw
241            .iter()
242            .zip(available)
243            .filter(|&(_, a)| *a)
244            .map(|(w, _)| w)
245            .sum();
246
247        if total_available <= 0.0 {
248            return [0.0; 8];
249        }
250
251        let scale = 1.0 / total_available;
252        let mut result = [0.0_f32; 8];
253        for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
254            result[i] = if avail { w * scale } else { 0.0 };
255        }
256        result
257    }
258}
259
260/// Quality grade based on score
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[non_exhaustive]
263pub enum QualityGrade {
264    /// Excellent: 90-100
265    A,
266    /// Good: 80-89
267    B,
268    /// Fair: 70-79
269    C,
270    /// Poor: 60-69
271    D,
272    /// Failing: <60
273    F,
274}
275
276impl QualityGrade {
277    /// Create grade from score
278    #[must_use]
279    pub const fn from_score(score: f32) -> Self {
280        // Guard against NaN (all comparisons return false) and out-of-range values
281        let clamped = if score > 100.0 {
282            100
283        } else if score >= 0.0 {
284            score as u32
285        } else {
286            0
287        };
288        match clamped {
289            90..=100 => Self::A,
290            80..=89 => Self::B,
291            70..=79 => Self::C,
292            60..=69 => Self::D,
293            _ => Self::F,
294        }
295    }
296
297    /// Get grade letter
298    #[must_use]
299    pub const fn letter(&self) -> &'static str {
300        match self {
301            Self::A => "A",
302            Self::B => "B",
303            Self::C => "C",
304            Self::D => "D",
305            Self::F => "F",
306        }
307    }
308
309    /// Get grade description
310    #[must_use]
311    pub const fn description(&self) -> &'static str {
312        match self {
313            Self::A => "Excellent",
314            Self::B => "Good",
315            Self::C => "Fair",
316            Self::D => "Poor",
317            Self::F => "Failing",
318        }
319    }
320}
321
322/// Recommendation for improving quality
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct Recommendation {
325    /// Priority (1 = highest, 5 = lowest)
326    pub priority: u8,
327    /// Category of the recommendation
328    pub category: RecommendationCategory,
329    /// Human-readable message
330    pub message: String,
331    /// Estimated impact on score (0-100)
332    pub impact: f32,
333    /// Affected components (if applicable)
334    pub affected_count: usize,
335}
336
337/// Single AI-readiness check result
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[non_exhaustive]
340pub struct AiCheck {
341    /// Machine-readable ID, e.g. "AI-001"
342    pub id: String,
343    /// Human-readable name
344    pub name: String,
345    /// Whether the check passed for every ML component
346    pub passed: bool,
347    /// Optional detail message (per-component pass/fail)
348    pub detail: Option<String>,
349    /// Relative weight of this check (0.0–1.0)
350    pub weight: f32,
351}
352
353/// AI/ML model-card completeness metrics (populated only for the `AiReadiness` profile)
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[non_exhaustive]
356pub struct AiReadinessMetrics {
357    /// Number of ML model components found
358    pub ml_component_count: usize,
359    /// True when no ML components were found — the score is N/A
360    pub not_applicable: bool,
361    /// Human-readable reason for N/A (when `not_applicable` is true)
362    pub na_reason: Option<String>,
363    /// Per-check results
364    pub checks: Vec<AiCheck>,
365    /// Number of ML components that passed every check
366    pub components_fully_documented: usize,
367}
368
369impl AiReadinessMetrics {
370    /// Whether AI readiness is not applicable to this SBOM (no ML components).
371    #[must_use]
372    pub const fn is_not_applicable(&self) -> bool {
373        self.not_applicable
374    }
375}
376
377/// Category for recommendations
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379#[non_exhaustive]
380pub enum RecommendationCategory {
381    Completeness,
382    Identifiers,
383    Licenses,
384    Vulnerabilities,
385    Dependencies,
386    Compliance,
387    Integrity,
388    Provenance,
389    Lifecycle,
390}
391
392impl RecommendationCategory {
393    #[must_use]
394    pub const fn name(&self) -> &'static str {
395        match self {
396            Self::Completeness => "Completeness",
397            Self::Identifiers => "Identifiers",
398            Self::Licenses => "Licenses",
399            Self::Vulnerabilities => "Vulnerabilities",
400            Self::Dependencies => "Dependencies",
401            Self::Compliance => "Compliance",
402            Self::Integrity => "Integrity",
403            Self::Provenance => "Provenance",
404            Self::Lifecycle => "Lifecycle",
405        }
406    }
407}
408
409/// Complete quality report for an SBOM
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[must_use]
412#[non_exhaustive]
413pub struct QualityReport {
414    /// Scoring engine version
415    pub scoring_engine_version: String,
416    /// Overall score (0-100)
417    pub overall_score: f32,
418    /// Overall grade
419    pub grade: QualityGrade,
420    /// Scoring profile used
421    pub profile: ScoringProfile,
422
423    // Individual category scores (0-100)
424    /// Completeness score
425    pub completeness_score: f32,
426    /// Identifier quality score
427    pub identifier_score: f32,
428    /// License quality score
429    pub license_score: f32,
430    /// Vulnerability documentation score (`None` if no vulnerability data)
431    pub vulnerability_score: Option<f32>,
432    /// Dependency graph quality score
433    pub dependency_score: f32,
434    /// Hash/integrity quality score
435    pub integrity_score: f32,
436    /// Provenance quality score (combined provenance + auditability)
437    pub provenance_score: f32,
438    /// Lifecycle quality score (`None` if no enrichment data)
439    pub lifecycle_score: Option<f32>,
440
441    // Detailed metrics
442    /// Detailed completeness metrics
443    pub completeness_metrics: CompletenessMetrics,
444    /// Detailed identifier metrics
445    pub identifier_metrics: IdentifierMetrics,
446    /// Detailed license metrics
447    pub license_metrics: LicenseMetrics,
448    /// Detailed vulnerability metrics
449    pub vulnerability_metrics: VulnerabilityMetrics,
450    /// Detailed dependency metrics
451    pub dependency_metrics: DependencyMetrics,
452    /// Hash/integrity metrics
453    pub hash_quality_metrics: HashQualityMetrics,
454    /// Provenance metrics
455    pub provenance_metrics: ProvenanceMetrics,
456    /// Auditability metrics
457    pub auditability_metrics: AuditabilityMetrics,
458    /// Lifecycle metrics (enrichment-dependent)
459    pub lifecycle_metrics: LifecycleMetrics,
460    /// Cryptography quality score (`None` if no crypto components)
461    pub cryptography_score: Option<f32>,
462    /// Cryptography metrics (CBOM)
463    pub cryptography_metrics: CryptographyMetrics,
464
465    /// Compliance check result
466    pub compliance: ComplianceResult,
467    /// Prioritized recommendations
468    pub recommendations: Vec<Recommendation>,
469    /// AI/ML readiness metrics (`Some` only when profile is `AiReadiness`)
470    pub ai_readiness_metrics: Option<AiReadinessMetrics>,
471}
472
473/// Quality scorer for SBOMs
474#[derive(Debug, Clone)]
475pub struct QualityScorer {
476    /// Scoring profile
477    profile: ScoringProfile,
478    /// Completeness weights
479    completeness_weights: CompletenessWeights,
480    /// Optional CRA sidecar metadata; when set, the embedded compliance check
481    /// (used to drive recommendations under `ScoringProfile::Cra`) consults
482    /// the sidecar for fields the SBOM doesn't carry.
483    cra_sidecar: Option<crate::model::CraSidecarMetadata>,
484    /// Optional CRA Annex III/IV product class. Sidecar `productClass` (when
485    /// present on `cra_sidecar`) wins over this value at check time.
486    cra_product_class: Option<crate::model::CraProductClass>,
487}
488
489impl QualityScorer {
490    /// Create a new quality scorer with the given profile
491    #[must_use]
492    pub fn new(profile: ScoringProfile) -> Self {
493        Self {
494            profile,
495            completeness_weights: CompletenessWeights::default(),
496            cra_sidecar: None,
497            cra_product_class: None,
498        }
499    }
500
501    /// Set custom completeness weights
502    #[must_use]
503    pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
504        self.completeness_weights = weights;
505        self
506    }
507
508    /// Attach CRA sidecar metadata for the embedded compliance check.
509    #[must_use]
510    pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
511        self.cra_sidecar = Some(sidecar);
512        self
513    }
514
515    /// Set the CRA Annex III/IV product class explicitly (for severity
516    /// calibration when the embedded compliance check runs under
517    /// `ScoringProfile::Cra`). Sidecar `productClass` overrides this.
518    #[must_use]
519    pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
520        self.cra_product_class = Some(class);
521        self
522    }
523
524    /// Score an SBOM
525    pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
526        // AI readiness uses a dedicated scoring path that is incompatible with the
527        // standard 8-category pipeline.
528        if self.profile == ScoringProfile::AiReadiness {
529            return self.score_ai_readiness(sbom);
530        }
531
532        let total_components = sbom.components.len();
533        let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
534
535        // Calculate all metrics
536        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
537        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
538        let license_metrics = LicenseMetrics::from_sbom(sbom);
539        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
540        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
541        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
542        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
543        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
544        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
545        let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
546
547        // Calculate individual category scores
548        let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
549        let identifier_score = identifier_metrics.quality_score(total_components);
550        let license_score = license_metrics.quality_score(total_components);
551        let vulnerability_score = vulnerability_metrics.documentation_score();
552        let dependency_score = dependency_metrics.quality_score(total_components);
553        let integrity_score = hash_quality_metrics.quality_score(total_components);
554        let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
555        let auditability_raw = auditability_metrics.quality_score(total_components);
556        // Combine provenance and auditability (60/40 split)
557        let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
558        let lifecycle_score = lifecycle_metrics.quality_score();
559        let cryptography_score = cryptography_metrics.quality_score();
560
561        // For CBOM profile, substitute crypto-specific scores into the 8 slots
562        let is_cbom = self.profile == ScoringProfile::Cbom;
563        let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
564            let cm = &cryptography_metrics;
565            (
566                [true; 8], // all categories available for CBOM
567                [
568                    cm.crypto_completeness_score(), // slot 1: Crpt
569                    cm.crypto_identifier_score(),   // slot 2: OIDs
570                    cm.algorithm_strength_score(),  // slot 3: Algo
571                    cm.crypto_dependency_score(),   // slot 4: Refs
572                    cm.crypto_lifecycle_score(),    // slot 5: Life
573                    cm.pqc_readiness_score(),       // slot 6: PQC
574                    provenance_score,               // slot 7: Prov (standard)
575                    license_score,                  // slot 8: Lic  (standard)
576                ],
577            )
578        } else {
579            // Standard SBOM scoring
580            let vuln_available = vulnerability_score.is_some();
581            let lifecycle_available = lifecycle_score.is_some();
582            (
583                [
584                    true,                // completeness
585                    true,                // identifiers
586                    true,                // licenses
587                    vuln_available,      // vulnerabilities
588                    true,                // dependencies
589                    true,                // integrity
590                    true,                // provenance
591                    lifecycle_available, // lifecycle
592                ],
593                [
594                    completeness_score,
595                    identifier_score,
596                    license_score,
597                    vulnerability_score.unwrap_or(0.0),
598                    dependency_score,
599                    integrity_score,
600                    provenance_score,
601                    lifecycle_score.unwrap_or(0.0),
602                ],
603            )
604        };
605
606        // Calculate weighted overall score with N/A renormalization
607        let weights = self.profile.weights();
608        let norm = weights.renormalize(&available);
609
610        let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
611        overall_score = overall_score.min(100.0);
612
613        // Apply hard penalty caps for critical issues
614        overall_score = self.apply_score_caps(
615            overall_score,
616            &lifecycle_metrics,
617            &dependency_metrics,
618            &hash_quality_metrics,
619            &cryptography_metrics,
620            total_components,
621        );
622
623        // Run compliance check (with sidecar + product class if configured)
624        let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
625        if let Some(sc) = self.cra_sidecar.clone() {
626            compliance_checker = compliance_checker.with_sidecar(sc);
627        }
628        if let Some(c) = self.cra_product_class {
629            compliance_checker = compliance_checker.with_product_class(c);
630        }
631        let compliance = compliance_checker.check(sbom);
632
633        // Generate recommendations
634        let recommendations = self.generate_recommendations(
635            &completeness_metrics,
636            &identifier_metrics,
637            &license_metrics,
638            &dependency_metrics,
639            &hash_quality_metrics,
640            &provenance_metrics,
641            &lifecycle_metrics,
642            &compliance,
643            total_components,
644        );
645
646        QualityReport {
647            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
648            overall_score,
649            grade: QualityGrade::from_score(overall_score),
650            profile: self.profile,
651            completeness_score,
652            identifier_score,
653            license_score,
654            vulnerability_score,
655            dependency_score,
656            integrity_score,
657            provenance_score,
658            lifecycle_score,
659            completeness_metrics,
660            identifier_metrics,
661            license_metrics,
662            vulnerability_metrics,
663            dependency_metrics,
664            hash_quality_metrics,
665            provenance_metrics,
666            auditability_metrics,
667            lifecycle_metrics,
668            cryptography_score,
669            cryptography_metrics,
670            compliance,
671            recommendations,
672            ai_readiness_metrics: None,
673        }
674    }
675
676    /// Score ML model-card completeness for the AI-readiness profile.
677    ///
678    /// Filters to `MachineLearningModel` components and evaluates eleven checks
679    /// (AI-001..AI-011): AI-001..AI-009 cover model-card transparency, AI-010 is
680    /// a weight-hash integrity check, and AI-011 verifies the component is
681    /// connected to the vulnerability/exploitability tooling stack. The
682    /// returned `QualityReport` has all standard category scores zeroed/`None`;
683    /// the rich data lives in `ai_readiness_metrics`.
684    /// When the SBOM has no ML components the report is marked not-applicable.
685    fn score_ai_readiness(&self, sbom: &NormalizedSbom) -> QualityReport {
686        use crate::model::ComponentType;
687
688        // Standard metrics are still computed so the report is structurally valid.
689        let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
690        let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
691        let license_metrics = LicenseMetrics::from_sbom(sbom);
692        let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
693        let dependency_metrics = DependencyMetrics::from_sbom(sbom);
694        let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
695        let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
696        let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
697        let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
698
699        let compliance = ComplianceChecker::new(self.profile.compliance_level()).check(sbom);
700
701        let make_report = |overall_score: f32,
702                           grade: QualityGrade,
703                           recommendations: Vec<Recommendation>,
704                           metrics: AiReadinessMetrics| QualityReport {
705            scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
706            overall_score,
707            grade,
708            profile: self.profile,
709            completeness_score: 0.0,
710            identifier_score: 0.0,
711            license_score: 0.0,
712            vulnerability_score: None,
713            dependency_score: 0.0,
714            integrity_score: 0.0,
715            provenance_score: 0.0,
716            lifecycle_score: None,
717            completeness_metrics: completeness_metrics.clone(),
718            identifier_metrics: identifier_metrics.clone(),
719            license_metrics: license_metrics.clone(),
720            vulnerability_metrics: vulnerability_metrics.clone(),
721            dependency_metrics: dependency_metrics.clone(),
722            hash_quality_metrics: hash_quality_metrics.clone(),
723            provenance_metrics: provenance_metrics.clone(),
724            auditability_metrics: auditability_metrics.clone(),
725            lifecycle_metrics: lifecycle_metrics.clone(),
726            cryptography_score: None,
727            cryptography_metrics: CryptographyMetrics::default(),
728            compliance: compliance.clone(),
729            recommendations,
730            ai_readiness_metrics: Some(metrics),
731        };
732
733        let ml_components: Vec<_> = sbom
734            .components
735            .values()
736            .filter(|c| c.component_type == ComponentType::MachineLearningModel)
737            .collect();
738
739        if ml_components.is_empty() {
740            let metrics = AiReadinessMetrics {
741                ml_component_count: 0,
742                not_applicable: true,
743                na_reason: Some(
744                    "No machine-learning-model components found in this SBOM".to_string(),
745                ),
746                checks: Vec::new(),
747                components_fully_documented: 0,
748            };
749            return make_report(0.0, QualityGrade::F, Vec::new(), metrics);
750        }
751
752        // Per-check (id, name, weight). AI-010 adds the integrity dimension —
753        // model-weight tampering is the canonical AI supply-chain attack — so it
754        // carries weight comparable to the transparency checks. The literals are
755        // chosen for readability; they no longer sum to exactly 1.0 once AI-010 is
756        // added, so they are renormalized below to keep the total at 1.0.
757        const CHECK_DEFS: [(&str, &str, f32); 11] = [
758            ("AI-001", "Model card URL present", 0.15),
759            ("AI-002", "Architecture family declared", 0.12),
760            ("AI-003", "Training datasets referenced", 0.12),
761            ("AI-004", "Quantitative analysis present", 0.12),
762            ("AI-005", "Fairness assessments included", 0.11),
763            ("AI-006", "Energy consumption disclosed", 0.10),
764            ("AI-007", "Use-cases documented", 0.10),
765            ("AI-008", "Known limitations stated", 0.09),
766            ("AI-009", "Ethical considerations present", 0.09),
767            ("AI-010", "Model weight hashes present", 0.12),
768            // AI-011 closes the BSI "vulnerability/exploitability referencing"
769            // gap for AI clusters: a model is only connected to the security
770            // tooling stack if it carries a CVE/advisory reference that OSV/KEV
771            // /EPSS/VEX can act on. Weighted like the integrity check (AI-010).
772            ("AI-011", "Exploitability/advisory reference present", 0.12),
773        ];
774
775        // Renormalize the per-check weights so they sum to exactly 1.0. Without
776        // this the literals above total 1.12 and a fully documented model would
777        // score >100 (before the .min(100.0) clamp), distorting partial scores.
778        let weight_sum: f32 = CHECK_DEFS.iter().map(|(_, _, w)| *w).sum();
779
780        let n = ml_components.len();
781        let mut total_weighted_score = 0.0_f32;
782        let mut components_fully_documented = 0_usize;
783        let mut component_details: Vec<Vec<String>> = vec![Vec::new(); CHECK_DEFS.len()];
784        let mut failing_components = vec![0_usize; CHECK_DEFS.len()];
785
786        for component in &ml_components {
787            let ml = component.ml_model.as_ref();
788            let raw = component.extensions.raw.as_ref();
789
790            let results: [bool; 11] = [
791                // AI-001: model card URL
792                ml.and_then(|m| m.model_card_url.as_ref()).is_some(),
793                // AI-002: architecture family
794                ml.and_then(|m| m.architecture_family.as_ref()).is_some(),
795                // AI-003: training datasets
796                ml.is_some_and(|m| !m.training_datasets.is_empty()),
797                // AI-004: quantitative analysis — typed performance metrics, with
798                // a raw-pointer fallback for SBOMs parsed before typed extraction.
799                ml.is_some_and(|m| !m.performance_metrics.is_empty())
800                    || has_non_empty_pointer(
801                        raw,
802                        &[
803                            "/modelCard/quantitativeAnalysis",
804                            "/mlModel/modelCard/quantitativeAnalysis",
805                        ],
806                    ),
807                // AI-005: fairness assessments. Fallback pointer corrected to the
808                // spec path `fairnessAssessments` (was the non-spec ...Considerations).
809                ml.is_some_and(|m| !m.fairness.is_empty())
810                    || has_non_empty_pointer(
811                        raw,
812                        &[
813                            "/modelCard/considerations/fairnessAssessments",
814                            "/mlModel/modelCard/considerations/fairnessAssessments",
815                            "/mlModel/considerations/fairnessAssessments",
816                            // Legacy non-spec key, retained for back-compat.
817                            "/modelCard/considerations/fairnessConsiderations",
818                            "/mlModel/modelCard/considerations/fairnessConsiderations",
819                            "/mlModel/considerations/fairnessConsiderations",
820                        ],
821                    ),
822                // AI-006: energy consumption
823                ml.and_then(|m| m.energy_kwh_training).is_some(),
824                // AI-007: use-cases
825                ml.is_some_and(|m| !m.use_cases.is_empty())
826                    || has_non_empty_pointer(
827                        raw,
828                        &[
829                            "/modelCard/considerations/useCases",
830                            "/mlModel/modelCard/considerations/useCases",
831                            "/mlModel/considerations/useCases",
832                        ],
833                    ),
834                // AI-008: limitations
835                ml.and_then(|m| m.limitations.as_ref()).is_some(),
836                // AI-009: ethical considerations
837                ml.is_some_and(|m| !m.ethical_considerations.is_empty())
838                    || has_non_empty_pointer(
839                        raw,
840                        &[
841                            "/modelCard/considerations/ethicalConsiderations",
842                            "/mlModel/modelCard/considerations/ethicalConsiderations",
843                            "/mlModel/considerations/ethicalConsiderations",
844                        ],
845                    ),
846                // AI-010: model weight hashes present. Integrity check — a
847                // MachineLearningModel component must carry at least one hash so
848                // its weights can be verified against tampering. Hashes typically
849                // arrive via HuggingFace enrichment (siblings[].lfs.sha256).
850                !component.hashes.is_empty(),
851                // AI-011: exploitability/advisory reference present. The model is
852                // only connected to the cybersecurity tooling stack (OSV/KEV/EPSS
853                // /VEX) when it carries at least one vulnerability reference OR a
854                // security/advisory external reference an analyst can pivot on.
855                ml_has_exploitability_reference(component),
856            ];
857
858            if results.iter().all(|&p| p) {
859                components_fully_documented += 1;
860            }
861
862            total_weighted_score += results
863                .iter()
864                .zip(CHECK_DEFS.iter())
865                .map(|(&passed, (_, _, w))| if passed { *w / weight_sum } else { 0.0 })
866                .sum::<f32>();
867
868            for (i, &passed) in results.iter().enumerate() {
869                component_details[i].push(format!(
870                    "{}: {}",
871                    component.name,
872                    if passed { "pass" } else { "fail" }
873                ));
874                if !passed {
875                    failing_components[i] += 1;
876                }
877            }
878        }
879
880        let checks: Vec<AiCheck> = CHECK_DEFS
881            .iter()
882            .enumerate()
883            .map(|(i, (id, name, weight))| {
884                let failures = failing_components[i];
885                let detail = if component_details[i].is_empty() {
886                    None
887                } else {
888                    Some(format!(
889                        "{}/{} components passed; {}",
890                        n - failures,
891                        n,
892                        component_details[i].join("; ")
893                    ))
894                };
895                AiCheck {
896                    id: (*id).to_string(),
897                    name: (*name).to_string(),
898                    passed: failures == 0,
899                    detail,
900                    // Expose the renormalized weight so reported weights sum to 1.0.
901                    weight: *weight / weight_sum,
902                }
903            })
904            .collect();
905
906        // Average across all ML components, scaled to 0-100.
907        let overall_score = ((total_weighted_score / n as f32) * 100.0).min(100.0);
908
909        let mut recommendations: Vec<Recommendation> = checks
910            .iter()
911            .zip(failing_components.iter())
912            .filter(|(c, _)| !c.passed)
913            .enumerate()
914            .map(|(i, (chk, &affected_count))| Recommendation {
915                priority: (i as u8 / 3) + 1,
916                category: RecommendationCategory::Completeness,
917                message: format!("[{}] {}", chk.id, chk.name),
918                impact: chk.weight * 100.0,
919                affected_count,
920            })
921            .collect();
922
923        recommendations.sort_by(|a, b| {
924            a.priority.cmp(&b.priority).then_with(|| {
925                b.impact
926                    .partial_cmp(&a.impact)
927                    .unwrap_or(std::cmp::Ordering::Equal)
928            })
929        });
930
931        let metrics = AiReadinessMetrics {
932            ml_component_count: n,
933            not_applicable: false,
934            na_reason: None,
935            checks,
936            components_fully_documented,
937        };
938
939        make_report(
940            overall_score,
941            QualityGrade::from_score(overall_score),
942            recommendations,
943            metrics,
944        )
945    }
946
947    /// Apply hard score caps for critical issues
948    fn apply_score_caps(
949        &self,
950        mut score: f32,
951        lifecycle: &LifecycleMetrics,
952        deps: &DependencyMetrics,
953        hashes: &HashQualityMetrics,
954        crypto: &CryptographyMetrics,
955        total_components: usize,
956    ) -> f32 {
957        let is_security_profile =
958            matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
959
960        // EOL components: cap at D grade for security-focused profiles
961        if is_security_profile && lifecycle.eol_components > 0 {
962            score = score.min(69.0);
963        }
964
965        // Dependency cycles: cap at B grade
966        if deps.cycle_count > 0
967            && matches!(
968                self.profile,
969                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
970            )
971        {
972            score = score.min(89.0);
973        }
974
975        // No hashes at all: cap at C grade for Security profile
976        if matches!(self.profile, ScoringProfile::Security)
977            && total_components > 0
978            && hashes.components_with_any_hash == 0
979        {
980            score = score.min(79.0);
981        }
982
983        // Weak-only hashes: cap at B grade for Security profile
984        if matches!(self.profile, ScoringProfile::Security)
985            && hashes.components_with_weak_only > 0
986            && hashes.components_with_strong_hash == 0
987        {
988            score = score.min(89.0);
989        }
990
991        // CBOM-specific hard caps
992        if self.profile == ScoringProfile::Cbom && crypto.has_data() {
993            if crypto.weak_algorithm_count > 0 {
994                score = score.min(69.0);
995            }
996            if crypto.compromised_keys > 0 {
997                score = score.min(79.0);
998            }
999            if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
1000                score = score.min(79.0);
1001            }
1002        }
1003
1004        score
1005    }
1006
1007    #[allow(clippy::too_many_arguments)]
1008    fn generate_recommendations(
1009        &self,
1010        completeness: &CompletenessMetrics,
1011        identifiers: &IdentifierMetrics,
1012        licenses: &LicenseMetrics,
1013        dependencies: &DependencyMetrics,
1014        hashes: &HashQualityMetrics,
1015        provenance: &ProvenanceMetrics,
1016        lifecycle: &LifecycleMetrics,
1017        compliance: &ComplianceResult,
1018        total_components: usize,
1019    ) -> Vec<Recommendation> {
1020        let mut recommendations = Vec::new();
1021
1022        // Priority 1: Compliance errors
1023        if compliance.error_count > 0 {
1024            recommendations.push(Recommendation {
1025                priority: 1,
1026                category: RecommendationCategory::Compliance,
1027                message: format!(
1028                    "Fix {} compliance error(s) to meet {} requirements",
1029                    compliance.error_count,
1030                    compliance.level.name()
1031                ),
1032                impact: 20.0,
1033                affected_count: compliance.error_count,
1034            });
1035        }
1036
1037        // Priority 1: EOL components
1038        if lifecycle.eol_components > 0 {
1039            recommendations.push(Recommendation {
1040                priority: 1,
1041                category: RecommendationCategory::Lifecycle,
1042                message: format!(
1043                    "{} component(s) have reached end-of-life — upgrade or replace",
1044                    lifecycle.eol_components
1045                ),
1046                impact: 15.0,
1047                affected_count: lifecycle.eol_components,
1048            });
1049        }
1050
1051        // Priority 1: Missing versions (critical for identification)
1052        let missing_versions = total_components
1053            - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
1054        if missing_versions > 0 {
1055            recommendations.push(Recommendation {
1056                priority: 1,
1057                category: RecommendationCategory::Completeness,
1058                message: "Add version information to all components".to_string(),
1059                impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
1060                affected_count: missing_versions,
1061            });
1062        }
1063
1064        // Priority 2: Weak-only hashes
1065        if hashes.components_with_weak_only > 0 {
1066            recommendations.push(Recommendation {
1067                priority: 2,
1068                category: RecommendationCategory::Integrity,
1069                message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
1070                impact: 10.0,
1071                affected_count: hashes.components_with_weak_only,
1072            });
1073        }
1074
1075        // Priority 2: Missing PURLs (important for identification)
1076        if identifiers.missing_all_identifiers > 0 {
1077            recommendations.push(Recommendation {
1078                priority: 2,
1079                category: RecommendationCategory::Identifiers,
1080                message: "Add PURL or CPE identifiers to components".to_string(),
1081                impact: (identifiers.missing_all_identifiers as f32
1082                    / total_components.max(1) as f32)
1083                    * 20.0,
1084                affected_count: identifiers.missing_all_identifiers,
1085            });
1086        }
1087
1088        // Priority 2: Invalid identifiers
1089        let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
1090        if invalid_ids > 0 {
1091            recommendations.push(Recommendation {
1092                priority: 2,
1093                category: RecommendationCategory::Identifiers,
1094                message: "Fix malformed PURL/CPE identifiers".to_string(),
1095                impact: 10.0,
1096                affected_count: invalid_ids,
1097            });
1098        }
1099
1100        // Priority 2: Missing tool creator info
1101        if !provenance.has_tool_creator {
1102            recommendations.push(Recommendation {
1103                priority: 2,
1104                category: RecommendationCategory::Provenance,
1105                message: "Add SBOM creation tool information".to_string(),
1106                impact: 8.0,
1107                affected_count: 0,
1108            });
1109        }
1110
1111        // Priority 3: Dependency cycles
1112        if dependencies.cycle_count > 0 {
1113            recommendations.push(Recommendation {
1114                priority: 3,
1115                category: RecommendationCategory::Dependencies,
1116                message: format!(
1117                    "{} dependency cycle(s) detected — review dependency graph",
1118                    dependencies.cycle_count
1119                ),
1120                impact: 10.0,
1121                affected_count: dependencies.cycle_count,
1122            });
1123        }
1124
1125        // Priority 2-3: Software complexity
1126        if let Some(level) = &dependencies.complexity_level {
1127            match level {
1128                super::metrics::ComplexityLevel::VeryHigh => {
1129                    recommendations.push(Recommendation {
1130                        priority: 2,
1131                        category: RecommendationCategory::Dependencies,
1132                        message:
1133                            "Dependency structure is very complex — review for unnecessary transitive dependencies"
1134                                .to_string(),
1135                        impact: 8.0,
1136                        affected_count: dependencies.total_dependencies,
1137                    });
1138                }
1139                super::metrics::ComplexityLevel::High => {
1140                    recommendations.push(Recommendation {
1141                        priority: 3,
1142                        category: RecommendationCategory::Dependencies,
1143                        message:
1144                            "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
1145                                .to_string(),
1146                        impact: 5.0,
1147                        affected_count: dependencies.total_dependencies,
1148                    });
1149                }
1150                _ => {}
1151            }
1152        }
1153
1154        // Priority 3: Missing licenses
1155        let missing_licenses = total_components - licenses.with_declared;
1156        if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
1157        {
1158            recommendations.push(Recommendation {
1159                priority: 3,
1160                category: RecommendationCategory::Licenses,
1161                message: "Add license information to components".to_string(),
1162                impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
1163                affected_count: missing_licenses,
1164            });
1165        }
1166
1167        // Priority 3: NOASSERTION licenses
1168        if licenses.noassertion_count > 0 {
1169            recommendations.push(Recommendation {
1170                priority: 3,
1171                category: RecommendationCategory::Licenses,
1172                message: "Replace NOASSERTION with actual license information".to_string(),
1173                impact: 5.0,
1174                affected_count: licenses.noassertion_count,
1175            });
1176        }
1177
1178        // Priority 3: VCS URL coverage
1179        if total_components > 0 {
1180            let missing_vcs = total_components.saturating_sub(
1181                ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
1182            );
1183            if missing_vcs > total_components / 2 {
1184                recommendations.push(Recommendation {
1185                    priority: 3,
1186                    category: RecommendationCategory::Provenance,
1187                    message: "Add VCS (source repository) URLs to components".to_string(),
1188                    impact: 5.0,
1189                    affected_count: missing_vcs,
1190                });
1191            }
1192        }
1193
1194        // Priority 4: Non-standard licenses
1195        if licenses.non_standard_licenses > 0 {
1196            recommendations.push(Recommendation {
1197                priority: 4,
1198                category: RecommendationCategory::Licenses,
1199                message: "Use SPDX license identifiers for better interoperability".to_string(),
1200                impact: 3.0,
1201                affected_count: licenses.non_standard_licenses,
1202            });
1203        }
1204
1205        // Priority 4: Outdated components
1206        if lifecycle.outdated_components > 0 {
1207            recommendations.push(Recommendation {
1208                priority: 4,
1209                category: RecommendationCategory::Lifecycle,
1210                message: format!(
1211                    "{} component(s) are outdated — newer versions available",
1212                    lifecycle.outdated_components
1213                ),
1214                impact: 5.0,
1215                affected_count: lifecycle.outdated_components,
1216            });
1217        }
1218
1219        // Priority 4: Missing completeness declaration
1220        if provenance.completeness_declaration == CompletenessDeclaration::Unknown
1221            && matches!(
1222                self.profile,
1223                ScoringProfile::Cra | ScoringProfile::Comprehensive
1224            )
1225        {
1226            recommendations.push(Recommendation {
1227                priority: 4,
1228                category: RecommendationCategory::Provenance,
1229                message: "Add compositions section with aggregate completeness declaration"
1230                    .to_string(),
1231                impact: 5.0,
1232                affected_count: 0,
1233            });
1234        }
1235
1236        // Priority 4: Missing dependency information
1237        if total_components > 1 && dependencies.total_dependencies == 0 {
1238            recommendations.push(Recommendation {
1239                priority: 4,
1240                category: RecommendationCategory::Dependencies,
1241                message: "Add dependency relationships between components".to_string(),
1242                impact: 10.0,
1243                affected_count: total_components,
1244            });
1245        }
1246
1247        // Priority 4: Many orphan components
1248        if dependencies.orphan_components > 1
1249            && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
1250        {
1251            recommendations.push(Recommendation {
1252                priority: 4,
1253                category: RecommendationCategory::Dependencies,
1254                message: "Review orphan components that have no dependency relationships"
1255                    .to_string(),
1256                impact: 5.0,
1257                affected_count: dependencies.orphan_components,
1258            });
1259        }
1260
1261        // Priority 5: Missing supplier information
1262        let missing_suppliers = total_components
1263            - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
1264        if missing_suppliers > 0
1265            && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
1266        {
1267            recommendations.push(Recommendation {
1268                priority: 5,
1269                category: RecommendationCategory::Completeness,
1270                message: "Add supplier information to components".to_string(),
1271                impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
1272                affected_count: missing_suppliers,
1273            });
1274        }
1275
1276        // Priority 5: Missing hashes
1277        let missing_hashes = total_components
1278            - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
1279        if missing_hashes > 0
1280            && matches!(
1281                self.profile,
1282                ScoringProfile::Security | ScoringProfile::Comprehensive
1283            )
1284        {
1285            recommendations.push(Recommendation {
1286                priority: 5,
1287                category: RecommendationCategory::Integrity,
1288                message: "Add cryptographic hashes for integrity verification".to_string(),
1289                impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
1290                affected_count: missing_hashes,
1291            });
1292        }
1293
1294        // Priority 5: Consider SBOM signing (only if not already signed)
1295        if !provenance.has_signature
1296            && matches!(
1297                self.profile,
1298                ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
1299            )
1300        {
1301            recommendations.push(Recommendation {
1302                priority: 5,
1303                category: RecommendationCategory::Integrity,
1304                message: "Consider adding a digital signature to the SBOM".to_string(),
1305                impact: 3.0,
1306                affected_count: 0,
1307            });
1308        }
1309
1310        // Sort by priority, then by impact
1311        recommendations.sort_by(|a, b| {
1312            a.priority.cmp(&b.priority).then_with(|| {
1313                b.impact
1314                    .partial_cmp(&a.impact)
1315                    .unwrap_or(std::cmp::Ordering::Equal)
1316            })
1317        });
1318
1319        recommendations
1320    }
1321}
1322
1323impl Default for QualityScorer {
1324    fn default() -> Self {
1325        Self::new(ScoringProfile::Standard)
1326    }
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use crate::model::{Component, ComponentType, DocumentMetadata, MlModelInfo};
1333    use serde_json::json;
1334
1335    #[test]
1336    fn test_grade_from_score() {
1337        assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
1338        assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
1339        assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
1340        assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
1341        assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
1342    }
1343
1344    #[test]
1345    fn test_scoring_profile_compliance_level() {
1346        assert_eq!(
1347            ScoringProfile::Minimal.compliance_level(),
1348            ComplianceLevel::Minimum
1349        );
1350        assert_eq!(
1351            ScoringProfile::Security.compliance_level(),
1352            ComplianceLevel::NtiaMinimum
1353        );
1354        assert_eq!(
1355            ScoringProfile::Comprehensive.compliance_level(),
1356            ComplianceLevel::Comprehensive
1357        );
1358        assert_eq!(
1359            ScoringProfile::AiReadiness.compliance_level(),
1360            ComplianceLevel::Comprehensive
1361        );
1362    }
1363
1364    #[test]
1365    fn test_scoring_weights_sum_to_one() {
1366        let profiles = [
1367            ScoringProfile::Minimal,
1368            ScoringProfile::Standard,
1369            ScoringProfile::Security,
1370            ScoringProfile::LicenseCompliance,
1371            ScoringProfile::Cra,
1372            ScoringProfile::Comprehensive,
1373            ScoringProfile::Cbom,
1374            ScoringProfile::AiReadiness,
1375        ];
1376        for profile in &profiles {
1377            let w = profile.weights();
1378            let sum: f32 = w.as_array().iter().sum();
1379            assert!(
1380                (sum - 1.0).abs() < 0.01,
1381                "{profile:?} weights sum to {sum}, expected 1.0"
1382            );
1383        }
1384    }
1385
1386    #[test]
1387    fn test_renormalize_all_available() {
1388        let w = ScoringProfile::Standard.weights();
1389        let available = [true; 8];
1390        let norm = w.renormalize(&available);
1391        let sum: f32 = norm.iter().sum();
1392        assert!((sum - 1.0).abs() < 0.001);
1393    }
1394
1395    #[test]
1396    fn test_renormalize_lifecycle_unavailable() {
1397        let w = ScoringProfile::Standard.weights();
1398        let mut available = [true; 8];
1399        available[7] = false; // lifecycle
1400        let norm = w.renormalize(&available);
1401        let sum: f32 = norm.iter().sum();
1402        assert!((sum - 1.0).abs() < 0.001);
1403        assert_eq!(norm[7], 0.0);
1404    }
1405
1406    #[test]
1407    fn test_scoring_engine_version() {
1408        assert_eq!(SCORING_ENGINE_VERSION, "2.1");
1409    }
1410
1411    #[test]
1412    fn cbom_hard_cap_weak_algorithms() {
1413        use crate::model::{
1414            AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
1415            CryptoPrimitive, CryptoProperties, NormalizedSbom,
1416        };
1417
1418        let mut sbom = NormalizedSbom::default();
1419        // Add a weak crypto component (MD5 algorithm)
1420        let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
1421        comp.component_type = ComponentType::Cryptographic;
1422        comp.crypto_properties = Some(
1423            CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1424                AlgorithmProperties::new(CryptoPrimitive::Hash)
1425                    .with_algorithm_family("MD5".to_string())
1426                    .with_nist_quantum_security_level(0),
1427            ),
1428        );
1429        sbom.components
1430            .insert(CanonicalId::from_name_version("md5", None), comp);
1431
1432        let scorer = QualityScorer::new(ScoringProfile::Cbom);
1433        let report = scorer.score(&sbom);
1434        // Weak algorithm → D max (69)
1435        assert!(
1436            report.overall_score <= 69.0,
1437            "weak algo should cap at D, got {}",
1438            report.overall_score
1439        );
1440    }
1441
1442    fn ml_component(bom_ref: &str, name: &str, ml: MlModelInfo, raw: Value) -> Component {
1443        let mut component =
1444            Component::new(name.to_string(), bom_ref.to_string()).with_version("1.0.0".to_string());
1445        component.component_type = ComponentType::MachineLearningModel;
1446        component.ml_model = Some(ml);
1447        component.extensions.raw = Some(raw);
1448        component
1449    }
1450
1451    #[test]
1452    fn test_ai_readiness_not_applicable_without_ml_components() {
1453        let sbom = NormalizedSbom::new(DocumentMetadata::default());
1454        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1455        let metrics = report
1456            .ai_readiness_metrics
1457            .expect("AI readiness metrics should be present");
1458        assert!(metrics.is_not_applicable());
1459        assert_eq!(metrics.ml_component_count, 0);
1460        assert!(metrics.checks.is_empty());
1461    }
1462
1463    #[test]
1464    fn test_ai_readiness_reads_nested_model_card_extensions() {
1465        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1466        let ml = MlModelInfo {
1467            architecture_family: Some("transformer".to_string()),
1468            training_datasets: vec![crate::model::DatasetRef {
1469                reference: None,
1470                name: Some("wikipedia-2.5B".to_string()),
1471                purl: None,
1472            }],
1473            energy_kwh_training: Some(1500.0),
1474            model_card_url: Some("https://example.test/model-card".to_string()),
1475            limitations: Some("Only validated for English text".to_string()),
1476            ..MlModelInfo::default()
1477        };
1478        let raw = json!({
1479            "mlModel": {
1480                "modelCard": {
1481                    "quantitativeAnalysis": {
1482                        "performanceMetrics": [{ "type": "accuracy", "value": 0.97 }]
1483                    },
1484                    "considerations": {
1485                        "fairnessConsiderations": ["Assessed on demographic parity"],
1486                        "useCases": ["Document classification"],
1487                        "ethicalConsiderations": ["Human review required for sensitive domains"]
1488                    }
1489                }
1490            }
1491        });
1492        let mut component = ml_component("ml-1", "bert-base", ml, raw);
1493        // A weight hash makes the AI-010 integrity check pass.
1494        component.hashes.push(crate::model::Hash::new(
1495            crate::model::HashAlgorithm::Sha256,
1496            "a".repeat(64),
1497        ));
1498        // A vulnerability reference makes the AI-011 exploitability check pass.
1499        component
1500            .vulnerabilities
1501            .push(crate::model::VulnerabilityRef::new(
1502                "CVE-2024-0001".to_string(),
1503                crate::model::VulnerabilitySource::Cve,
1504            ));
1505        sbom.add_component(component);
1506
1507        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1508        let metrics = report
1509            .ai_readiness_metrics
1510            .expect("AI readiness metrics should be present");
1511        assert!(!metrics.is_not_applicable());
1512        // All eleven checks should pass → fully documented, perfect score.
1513        for check in &metrics.checks {
1514            assert!(check.passed, "expected {} to pass", check.id);
1515        }
1516        assert_eq!(metrics.checks.len(), 11, "AI-001..AI-011 are all reported");
1517        // The renormalized per-check weights must still sum to 1.0.
1518        let weight_total: f32 = metrics.checks.iter().map(|c| c.weight).sum();
1519        assert!(
1520            (weight_total - 1.0).abs() < 0.001,
1521            "renormalized weights must sum to 1.0, got {weight_total}"
1522        );
1523        assert_eq!(metrics.components_fully_documented, 1);
1524        assert!((report.overall_score - 100.0).abs() < 0.01);
1525        assert_eq!(report.grade, QualityGrade::A);
1526    }
1527
1528    #[test]
1529    fn test_ai_readiness_fails_check_when_any_model_is_missing_it() {
1530        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1531        let complete_ml = MlModelInfo {
1532            architecture_family: Some("transformer".to_string()),
1533            training_datasets: vec![crate::model::DatasetRef {
1534                reference: None,
1535                name: Some("dataset".to_string()),
1536                purl: None,
1537            }],
1538            energy_kwh_training: Some(10.0),
1539            model_card_url: Some("https://example.test/model-card".to_string()),
1540            limitations: Some("Only validated for English text".to_string()),
1541            ..MlModelInfo::default()
1542        };
1543        let complete_raw = json!({
1544            "mlModel": { "modelCard": {
1545                "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.98 }] },
1546                "considerations": {
1547                    "fairnessConsiderations": ["Reviewed"],
1548                    "useCases": ["Classification"],
1549                    "ethicalConsiderations": ["Human review required"]
1550                }
1551            }}
1552        });
1553        sbom.add_component(ml_component(
1554            "ml-1",
1555            "complete-model",
1556            complete_ml.clone(),
1557            complete_raw,
1558        ));
1559
1560        // Second model is missing fairness assessments.
1561        let incomplete_raw = json!({
1562            "mlModel": { "modelCard": {
1563                "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.94 }] },
1564                "considerations": {
1565                    "useCases": ["Classification"],
1566                    "ethicalConsiderations": ["Human review required"]
1567                }
1568            }}
1569        });
1570        sbom.add_component(ml_component(
1571            "ml-2",
1572            "incomplete-model",
1573            complete_ml,
1574            incomplete_raw,
1575        ));
1576
1577        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1578        let metrics = report
1579            .ai_readiness_metrics
1580            .expect("AI readiness metrics should be present");
1581        let fairness = metrics
1582            .checks
1583            .iter()
1584            .find(|c| c.id == "AI-005")
1585            .expect("AI-005 should be present");
1586        assert!(
1587            !fairness.passed,
1588            "AI-005 should fail when any model is missing fairness data"
1589        );
1590        assert!(
1591            fairness
1592                .detail
1593                .as_deref()
1594                .unwrap_or_default()
1595                .contains("1/2 components passed")
1596        );
1597        let rec = report
1598            .recommendations
1599            .iter()
1600            .find(|r| r.message.contains("AI-005"))
1601            .expect("missing fairness recommendation");
1602        assert_eq!(rec.affected_count, 1);
1603    }
1604
1605    #[test]
1606    fn test_ai_010_weight_hash_integrity_check() {
1607        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1608
1609        // A model with no hashes fails AI-010; one with a hash passes it.
1610        let bare = ml_component("ml-1", "no-hash", MlModelInfo::default(), json!({}));
1611        sbom.add_component(bare);
1612
1613        let mut hashed = ml_component("ml-2", "with-hash", MlModelInfo::default(), json!({}));
1614        hashed.hashes.push(crate::model::Hash::new(
1615            crate::model::HashAlgorithm::Sha256,
1616            "b".repeat(64),
1617        ));
1618        sbom.add_component(hashed);
1619
1620        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1621        let metrics = report
1622            .ai_readiness_metrics
1623            .expect("AI readiness metrics should be present");
1624
1625        let ai010 = metrics
1626            .checks
1627            .iter()
1628            .find(|c| c.id == "AI-010")
1629            .expect("AI-010 should be present");
1630        // One of two models lacks a hash, so the aggregate check fails.
1631        assert!(
1632            !ai010.passed,
1633            "AI-010 should fail when any model is missing weight hashes"
1634        );
1635        assert!(
1636            ai010
1637                .detail
1638                .as_deref()
1639                .unwrap_or_default()
1640                .contains("1/2 components passed"),
1641            "AI-010 detail should report 1/2 models passing"
1642        );
1643    }
1644
1645    #[test]
1646    fn test_ai_011_exploitability_reference_check() {
1647        use crate::model::{
1648            ExternalRefType, ExternalReference, VulnerabilityRef, VulnerabilitySource,
1649        };
1650
1651        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1652
1653        // Model 1: carries a vulnerability reference → AI-011 passes.
1654        let mut with_vuln = ml_component("ml-1", "with-vuln", MlModelInfo::default(), json!({}));
1655        with_vuln.vulnerabilities.push(VulnerabilityRef::new(
1656            "CVE-2024-1234".to_string(),
1657            VulnerabilitySource::Cve,
1658        ));
1659        sbom.add_component(with_vuln);
1660
1661        // Model 2: carries a security advisory external reference → AI-011 passes.
1662        let mut with_advisory =
1663            ml_component("ml-2", "with-advisory", MlModelInfo::default(), json!({}));
1664        with_advisory.external_refs.push(ExternalReference {
1665            ref_type: ExternalRefType::Advisories,
1666            url: "https://example.test/advisory".to_string(),
1667            comment: None,
1668            hashes: Vec::new(),
1669        });
1670        sbom.add_component(with_advisory);
1671
1672        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1673        let metrics = report
1674            .ai_readiness_metrics
1675            .expect("AI readiness metrics should be present");
1676        let ai011 = metrics
1677            .checks
1678            .iter()
1679            .find(|c| c.id == "AI-011")
1680            .expect("AI-011 should be present");
1681        assert!(
1682            ai011.passed,
1683            "AI-011 should pass when every model carries a vuln or advisory reference"
1684        );
1685    }
1686
1687    #[test]
1688    fn test_ai_011_fails_without_exploitability_reference() {
1689        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1690        // A model with neither a vuln ref nor an advisory external reference.
1691        sbom.add_component(ml_component(
1692            "ml-1",
1693            "no-refs",
1694            MlModelInfo::default(),
1695            json!({}),
1696        ));
1697
1698        let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1699        let metrics = report
1700            .ai_readiness_metrics
1701            .expect("AI readiness metrics should be present");
1702        let ai011 = metrics
1703            .checks
1704            .iter()
1705            .find(|c| c.id == "AI-011")
1706            .expect("AI-011 should be present");
1707        assert!(
1708            !ai011.passed,
1709            "AI-011 should fail when a model has no exploitability/advisory reference"
1710        );
1711    }
1712}