1use 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
17pub const SCORING_ENGINE_VERSION: &str = "2.1";
19
20fn 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
36fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
64#[non_exhaustive]
65pub enum ScoringProfile {
66 #[value(name = "minimal")]
68 Minimal,
69 #[value(name = "standard")]
71 Standard,
72 #[value(name = "security")]
74 Security,
75 #[value(name = "license-compliance", alias = "license")]
77 LicenseCompliance,
78 #[value(name = "cra", alias = "cyber-resilience")]
80 Cra,
81 #[value(
85 name = "bsi",
86 alias = "tr-03183",
87 alias = "tr03183",
88 alias = "bsi-tr-03183-2"
89 )]
90 BsiTr03183_2,
91 #[value(name = "comprehensive", alias = "full")]
93 Comprehensive,
94 #[value(name = "cbom", alias = "cryptographic")]
96 Cbom,
97 #[value(name = "ai-readiness", alias = "ai_readiness")]
99 AiReadiness,
100}
101
102impl std::fmt::Display for ScoringProfile {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(self.canonical_name())
105 }
106}
107
108impl std::str::FromStr for ScoringProfile {
109 type Err = String;
110
111 fn from_str(s: &str) -> Result<Self, Self::Err> {
113 let s = s.trim();
114 for variant in <Self as clap::ValueEnum>::value_variants() {
115 if clap::ValueEnum::to_possible_value(variant).is_some_and(|pv| pv.matches(s, true)) {
116 return Ok(*variant);
117 }
118 }
119 Err(format!(
120 "unknown scoring profile '{s}'. Valid values: {}",
121 Self::valid_values()
122 ))
123 }
124}
125
126impl ScoringProfile {
127 #[must_use]
129 pub const fn canonical_name(self) -> &'static str {
130 match self {
131 Self::Minimal => "minimal",
132 Self::Standard => "standard",
133 Self::Security => "security",
134 Self::LicenseCompliance => "license-compliance",
135 Self::Cra => "cra",
136 Self::BsiTr03183_2 => "bsi",
137 Self::Comprehensive => "comprehensive",
138 Self::Cbom => "cbom",
139 Self::AiReadiness => "ai-readiness",
140 }
141 }
142
143 #[must_use]
145 pub fn valid_values() -> String {
146 <Self as clap::ValueEnum>::value_variants()
147 .iter()
148 .map(|v| v.canonical_name())
149 .collect::<Vec<_>>()
150 .join(", ")
151 }
152
153 #[must_use]
155 pub const fn compliance_level(&self) -> ComplianceLevel {
156 match self {
157 Self::Minimal => ComplianceLevel::Minimum,
158 Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
159 Self::Security => ComplianceLevel::NtiaMinimum,
160 Self::Cra => ComplianceLevel::CraPhase2,
161 Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
162 Self::Comprehensive => ComplianceLevel::Comprehensive,
163 Self::Cbom => ComplianceLevel::Comprehensive,
164 Self::AiReadiness => ComplianceLevel::Comprehensive,
165 }
166 }
167
168 const fn weights(self) -> ScoringWeights {
173 match self {
174 Self::Minimal => ScoringWeights {
175 completeness: 0.35,
176 identifiers: 0.20,
177 licenses: 0.10,
178 vulnerabilities: 0.05,
179 dependencies: 0.10,
180 integrity: 0.05,
181 provenance: 0.10,
182 lifecycle: 0.05,
183 },
184 Self::Standard => ScoringWeights {
185 completeness: 0.25,
186 identifiers: 0.20,
187 licenses: 0.12,
188 vulnerabilities: 0.08,
189 dependencies: 0.10,
190 integrity: 0.08,
191 provenance: 0.10,
192 lifecycle: 0.07,
193 },
194 Self::Security => ScoringWeights {
195 completeness: 0.12,
196 identifiers: 0.18,
197 licenses: 0.05,
198 vulnerabilities: 0.20,
199 dependencies: 0.10,
200 integrity: 0.15,
201 provenance: 0.10,
202 lifecycle: 0.10,
203 },
204 Self::LicenseCompliance => ScoringWeights {
205 completeness: 0.15,
206 identifiers: 0.12,
207 licenses: 0.35,
208 vulnerabilities: 0.05,
209 dependencies: 0.10,
210 integrity: 0.05,
211 provenance: 0.10,
212 lifecycle: 0.08,
213 },
214 Self::Cra => ScoringWeights {
215 completeness: 0.12,
216 identifiers: 0.18,
217 licenses: 0.08,
218 vulnerabilities: 0.15,
219 dependencies: 0.12,
220 integrity: 0.12,
221 provenance: 0.15,
222 lifecycle: 0.08,
223 },
224 Self::BsiTr03183_2 => ScoringWeights {
228 completeness: 0.10,
229 identifiers: 0.22,
230 licenses: 0.08,
231 vulnerabilities: 0.10,
232 dependencies: 0.12,
233 integrity: 0.18,
234 provenance: 0.12,
235 lifecycle: 0.08,
236 },
237 Self::Comprehensive => ScoringWeights {
238 completeness: 0.15,
239 identifiers: 0.13,
240 licenses: 0.13,
241 vulnerabilities: 0.10,
242 dependencies: 0.12,
243 integrity: 0.12,
244 provenance: 0.13,
245 lifecycle: 0.12,
246 },
247 Self::Cbom => ScoringWeights {
252 completeness: 0.15,
253 identifiers: 0.15,
254 licenses: 0.22,
255 vulnerabilities: 0.10,
256 dependencies: 0.13,
257 integrity: 0.15,
258 provenance: 0.08,
259 lifecycle: 0.02,
260 },
261 Self::AiReadiness => ScoringWeights {
264 completeness: 0.25,
265 identifiers: 0.15,
266 licenses: 0.15,
267 vulnerabilities: 0.10,
268 dependencies: 0.10,
269 integrity: 0.08,
270 provenance: 0.10,
271 lifecycle: 0.07,
272 },
273 }
274 }
275}
276
277#[derive(Debug, Clone)]
279struct ScoringWeights {
280 completeness: f32,
281 identifiers: f32,
282 licenses: f32,
283 vulnerabilities: f32,
284 dependencies: f32,
285 integrity: f32,
286 provenance: f32,
287 lifecycle: f32,
288}
289
290impl ScoringWeights {
291 fn as_array(&self) -> [f32; 8] {
293 [
294 self.completeness,
295 self.identifiers,
296 self.licenses,
297 self.vulnerabilities,
298 self.dependencies,
299 self.integrity,
300 self.provenance,
301 self.lifecycle,
302 ]
303 }
304
305 fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
310 let raw = self.as_array();
311 let total_available: f32 = raw
312 .iter()
313 .zip(available)
314 .filter(|&(_, a)| *a)
315 .map(|(w, _)| w)
316 .sum();
317
318 if total_available <= 0.0 {
319 return [0.0; 8];
320 }
321
322 let scale = 1.0 / total_available;
323 let mut result = [0.0_f32; 8];
324 for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
325 result[i] = if avail { w * scale } else { 0.0 };
326 }
327 result
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[non_exhaustive]
334pub enum QualityGrade {
335 A,
337 B,
339 C,
341 D,
343 F,
345}
346
347impl QualityGrade {
348 #[must_use]
350 pub const fn from_score(score: f32) -> Self {
351 let clamped = if score > 100.0 {
353 100
354 } else if score >= 0.0 {
355 score as u32
356 } else {
357 0
358 };
359 match clamped {
360 90..=100 => Self::A,
361 80..=89 => Self::B,
362 70..=79 => Self::C,
363 60..=69 => Self::D,
364 _ => Self::F,
365 }
366 }
367
368 #[must_use]
370 pub const fn letter(&self) -> &'static str {
371 match self {
372 Self::A => "A",
373 Self::B => "B",
374 Self::C => "C",
375 Self::D => "D",
376 Self::F => "F",
377 }
378 }
379
380 #[must_use]
382 pub const fn description(&self) -> &'static str {
383 match self {
384 Self::A => "Excellent",
385 Self::B => "Good",
386 Self::C => "Fair",
387 Self::D => "Poor",
388 Self::F => "Failing",
389 }
390 }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct Recommendation {
396 pub priority: u8,
398 pub category: RecommendationCategory,
400 pub message: String,
402 pub impact: f32,
404 pub affected_count: usize,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410#[non_exhaustive]
411pub struct AiCheck {
412 pub id: String,
414 pub name: String,
416 pub passed: bool,
418 pub detail: Option<String>,
420 pub weight: f32,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426#[non_exhaustive]
427pub struct AiReadinessMetrics {
428 pub ml_component_count: usize,
430 pub not_applicable: bool,
432 pub na_reason: Option<String>,
434 pub checks: Vec<AiCheck>,
436 pub components_fully_documented: usize,
438}
439
440impl AiReadinessMetrics {
441 #[must_use]
443 pub const fn is_not_applicable(&self) -> bool {
444 self.not_applicable
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450#[non_exhaustive]
451pub enum RecommendationCategory {
452 Completeness,
453 Identifiers,
454 Licenses,
455 Vulnerabilities,
456 Dependencies,
457 Compliance,
458 Integrity,
459 Provenance,
460 Lifecycle,
461}
462
463impl RecommendationCategory {
464 #[must_use]
465 pub const fn name(&self) -> &'static str {
466 match self {
467 Self::Completeness => "Completeness",
468 Self::Identifiers => "Identifiers",
469 Self::Licenses => "Licenses",
470 Self::Vulnerabilities => "Vulnerabilities",
471 Self::Dependencies => "Dependencies",
472 Self::Compliance => "Compliance",
473 Self::Integrity => "Integrity",
474 Self::Provenance => "Provenance",
475 Self::Lifecycle => "Lifecycle",
476 }
477 }
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
482#[must_use]
483#[non_exhaustive]
484pub struct QualityReport {
485 pub scoring_engine_version: String,
487 pub overall_score: f32,
489 pub grade: QualityGrade,
491 pub profile: ScoringProfile,
493
494 pub completeness_score: f32,
497 pub identifier_score: f32,
499 pub license_score: f32,
501 pub vulnerability_score: Option<f32>,
503 pub dependency_score: f32,
505 pub integrity_score: f32,
507 pub provenance_score: f32,
509 pub lifecycle_score: Option<f32>,
511
512 pub completeness_metrics: CompletenessMetrics,
515 pub identifier_metrics: IdentifierMetrics,
517 pub license_metrics: LicenseMetrics,
519 pub vulnerability_metrics: VulnerabilityMetrics,
521 pub dependency_metrics: DependencyMetrics,
523 pub hash_quality_metrics: HashQualityMetrics,
525 pub provenance_metrics: ProvenanceMetrics,
527 pub auditability_metrics: AuditabilityMetrics,
529 pub lifecycle_metrics: LifecycleMetrics,
531 pub cryptography_score: Option<f32>,
533 pub cryptography_metrics: CryptographyMetrics,
535
536 pub compliance: ComplianceResult,
538 pub recommendations: Vec<Recommendation>,
540 pub ai_readiness_metrics: Option<AiReadinessMetrics>,
542}
543
544#[derive(Debug, Clone)]
546pub struct QualityScorer {
547 profile: ScoringProfile,
549 completeness_weights: CompletenessWeights,
551 cra_sidecar: Option<crate::model::CraSidecarMetadata>,
555 cra_product_class: Option<crate::model::CraProductClass>,
558 as_of: Option<chrono::DateTime<chrono::Utc>>,
563}
564
565impl QualityScorer {
566 #[must_use]
568 pub fn new(profile: ScoringProfile) -> Self {
569 Self {
570 profile,
571 completeness_weights: CompletenessWeights::default(),
572 cra_sidecar: None,
573 cra_product_class: None,
574 as_of: None,
575 }
576 }
577
578 #[must_use]
580 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
581 self.completeness_weights = weights;
582 self
583 }
584
585 #[must_use]
587 pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
588 self.cra_sidecar = Some(sidecar);
589 self
590 }
591
592 #[must_use]
596 pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
597 self.cra_product_class = Some(class);
598 self
599 }
600
601 #[must_use]
606 pub const fn with_as_of(mut self, as_of: chrono::DateTime<chrono::Utc>) -> Self {
607 self.as_of = Some(as_of);
608 self
609 }
610
611 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
613 if self.profile == ScoringProfile::AiReadiness {
616 return self.score_ai_readiness(sbom);
617 }
618
619 let total_components = sbom.components.len();
620 let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
621
622 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
624 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
625 let license_metrics = LicenseMetrics::from_sbom(sbom);
626 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
627 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
628 let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
629 let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
630 let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
631 let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
632 let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
633
634 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
636 let identifier_score = identifier_metrics.quality_score(total_components);
637 let license_score = license_metrics.quality_score(total_components);
638 let vulnerability_score = vulnerability_metrics.documentation_score();
639 let dependency_score = dependency_metrics.quality_score(total_components);
640 let integrity_score = hash_quality_metrics.quality_score(total_components);
641 let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
642 let auditability_raw = auditability_metrics.quality_score(total_components);
643 let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
645 let lifecycle_score = lifecycle_metrics.quality_score();
646 let cryptography_score = cryptography_metrics.quality_score();
647
648 let is_cbom = self.profile == ScoringProfile::Cbom;
650 let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
651 let cm = &cryptography_metrics;
652 let pqc_score = cm.pqc_readiness_score();
657 (
658 [
659 true, true, true, true, true, pqc_score.is_some(), true, true, ],
668 [
669 cm.crypto_completeness_score(), cm.crypto_identifier_score(), cm.algorithm_strength_score(), cm.crypto_dependency_score(), cm.crypto_lifecycle_score(), pqc_score.unwrap_or(0.0), provenance_score, license_score, ],
678 )
679 } else {
680 let vuln_available = vulnerability_score.is_some();
682 let lifecycle_available = lifecycle_score.is_some();
683 (
684 [
685 true, true, true, vuln_available, true, true, true, lifecycle_available, ],
694 [
695 completeness_score,
696 identifier_score,
697 license_score,
698 vulnerability_score.unwrap_or(0.0),
699 dependency_score,
700 integrity_score,
701 provenance_score,
702 lifecycle_score.unwrap_or(0.0),
703 ],
704 )
705 };
706
707 let weights = self.profile.weights();
709 let norm = weights.renormalize(&available);
710
711 let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
712 overall_score = overall_score.min(100.0);
713
714 overall_score = self.apply_score_caps(
716 overall_score,
717 &lifecycle_metrics,
718 &dependency_metrics,
719 &hash_quality_metrics,
720 &cryptography_metrics,
721 total_components,
722 );
723
724 let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
727 if let Some(sc) = self.cra_sidecar.clone() {
728 compliance_checker = compliance_checker.with_sidecar(sc);
729 }
730 if let Some(c) = self.cra_product_class {
731 compliance_checker = compliance_checker.with_product_class(c);
732 }
733 if let Some(t) = self.as_of {
734 compliance_checker = compliance_checker.with_as_of(t);
735 }
736 let compliance = compliance_checker.check(sbom);
737
738 let recommendations = self.generate_recommendations(
740 &completeness_metrics,
741 &identifier_metrics,
742 &license_metrics,
743 &dependency_metrics,
744 &hash_quality_metrics,
745 &provenance_metrics,
746 &lifecycle_metrics,
747 &auditability_metrics,
748 &compliance,
749 total_components,
750 );
751
752 QualityReport {
753 scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
754 overall_score,
755 grade: QualityGrade::from_score(overall_score),
756 profile: self.profile,
757 completeness_score,
758 identifier_score,
759 license_score,
760 vulnerability_score,
761 dependency_score,
762 integrity_score,
763 provenance_score,
764 lifecycle_score,
765 completeness_metrics,
766 identifier_metrics,
767 license_metrics,
768 vulnerability_metrics,
769 dependency_metrics,
770 hash_quality_metrics,
771 provenance_metrics,
772 auditability_metrics,
773 lifecycle_metrics,
774 cryptography_score,
775 cryptography_metrics,
776 compliance,
777 recommendations,
778 ai_readiness_metrics: None,
779 }
780 }
781
782 fn score_ai_readiness(&self, sbom: &NormalizedSbom) -> QualityReport {
799 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
801 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
802 let license_metrics = LicenseMetrics::from_sbom(sbom);
803 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
804 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
805 let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
806 let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
807 let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
808 let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
809
810 let compliance = {
811 let mut checker = ComplianceChecker::new(self.profile.compliance_level());
812 if let Some(t) = self.as_of {
813 checker = checker.with_as_of(t);
814 }
815 checker.check(sbom)
816 };
817
818 let make_report = |overall_score: f32,
819 grade: QualityGrade,
820 recommendations: Vec<Recommendation>,
821 metrics: AiReadinessMetrics| QualityReport {
822 scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
823 overall_score,
824 grade,
825 profile: self.profile,
826 completeness_score: 0.0,
827 identifier_score: 0.0,
828 license_score: 0.0,
829 vulnerability_score: None,
830 dependency_score: 0.0,
831 integrity_score: 0.0,
832 provenance_score: 0.0,
833 lifecycle_score: None,
834 completeness_metrics: completeness_metrics.clone(),
835 identifier_metrics: identifier_metrics.clone(),
836 license_metrics: license_metrics.clone(),
837 vulnerability_metrics: vulnerability_metrics.clone(),
838 dependency_metrics: dependency_metrics.clone(),
839 hash_quality_metrics: hash_quality_metrics.clone(),
840 provenance_metrics: provenance_metrics.clone(),
841 auditability_metrics: auditability_metrics.clone(),
842 lifecycle_metrics: lifecycle_metrics.clone(),
843 cryptography_score: None,
844 cryptography_metrics: CryptographyMetrics::default(),
845 compliance: compliance.clone(),
846 recommendations,
847 ai_readiness_metrics: Some(metrics),
848 };
849
850 let scope = super::compliance::ai_shared::ai_bom_scope(sbom);
858 let ml_components: Vec<_> = scope
859 .ml_components
860 .iter()
861 .chain(scope.untyped_ml_components.iter())
862 .copied()
863 .collect();
864
865 if ml_components.is_empty() {
866 let metrics = AiReadinessMetrics {
867 ml_component_count: 0,
868 not_applicable: true,
869 na_reason: Some(
870 "No machine-learning-model components found in this SBOM (by declared \
871 type, parsed ML-model metadata, or ML content signals)"
872 .to_string(),
873 ),
874 checks: Vec::new(),
875 components_fully_documented: 0,
876 };
877 return make_report(0.0, QualityGrade::F, Vec::new(), metrics);
878 }
879
880 const CHECK_DEFS: [(&str, &str, f32); 11] = [
886 ("AI-001", "Model card URL present", 0.15),
887 ("AI-002", "Architecture family declared", 0.12),
888 ("AI-003", "Training datasets referenced", 0.12),
889 ("AI-004", "Quantitative analysis present", 0.12),
890 ("AI-005", "Fairness assessments included", 0.11),
891 ("AI-006", "Energy consumption disclosed", 0.10),
892 ("AI-007", "Use-cases documented", 0.10),
893 ("AI-008", "Known limitations stated", 0.09),
894 ("AI-009", "Ethical considerations present", 0.09),
895 ("AI-010", "Model weight hashes present", 0.12),
896 ("AI-011", "Exploitability/advisory reference present", 0.12),
901 ];
902
903 let weight_sum: f32 = CHECK_DEFS.iter().map(|(_, _, w)| *w).sum();
907
908 let n = ml_components.len();
909 let mut total_weighted_score = 0.0_f32;
910 let mut components_fully_documented = 0_usize;
911 let mut component_details: Vec<Vec<String>> = vec![Vec::new(); CHECK_DEFS.len()];
912 let mut failing_components = vec![0_usize; CHECK_DEFS.len()];
913
914 for component in &ml_components {
915 let ml = component.ml_model.as_ref();
916 let raw = component.extensions.raw.as_ref();
917
918 let results: [bool; 11] = [
919 ml.and_then(|m| m.model_card_url.as_ref()).is_some(),
921 ml.and_then(|m| m.architecture_family.as_ref()).is_some(),
923 ml.is_some_and(|m| !m.training_datasets.is_empty()),
925 ml.is_some_and(|m| !m.performance_metrics.is_empty())
928 || has_non_empty_pointer(
929 raw,
930 &[
931 "/modelCard/quantitativeAnalysis",
932 "/mlModel/modelCard/quantitativeAnalysis",
933 ],
934 ),
935 ml.is_some_and(|m| !m.fairness.is_empty())
938 || has_non_empty_pointer(
939 raw,
940 &[
941 "/modelCard/considerations/fairnessAssessments",
942 "/mlModel/modelCard/considerations/fairnessAssessments",
943 "/mlModel/considerations/fairnessAssessments",
944 "/modelCard/considerations/fairnessConsiderations",
946 "/mlModel/modelCard/considerations/fairnessConsiderations",
947 "/mlModel/considerations/fairnessConsiderations",
948 ],
949 ),
950 ml.and_then(|m| m.energy_kwh_training).is_some(),
952 ml.is_some_and(|m| !m.use_cases.is_empty())
954 || has_non_empty_pointer(
955 raw,
956 &[
957 "/modelCard/considerations/useCases",
958 "/mlModel/modelCard/considerations/useCases",
959 "/mlModel/considerations/useCases",
960 ],
961 ),
962 ml.and_then(|m| m.limitations.as_ref()).is_some(),
964 ml.is_some_and(|m| !m.ethical_considerations.is_empty())
966 || has_non_empty_pointer(
967 raw,
968 &[
969 "/modelCard/considerations/ethicalConsiderations",
970 "/mlModel/modelCard/considerations/ethicalConsiderations",
971 "/mlModel/considerations/ethicalConsiderations",
972 ],
973 ),
974 !component.hashes.is_empty(),
979 ml_has_exploitability_reference(component),
984 ];
985
986 if results.iter().all(|&p| p) {
987 components_fully_documented += 1;
988 }
989
990 total_weighted_score += results
991 .iter()
992 .zip(CHECK_DEFS.iter())
993 .map(|(&passed, (_, _, w))| if passed { *w / weight_sum } else { 0.0 })
994 .sum::<f32>();
995
996 for (i, &passed) in results.iter().enumerate() {
997 component_details[i].push(format!(
998 "{}: {}",
999 component.name,
1000 if passed { "pass" } else { "fail" }
1001 ));
1002 if !passed {
1003 failing_components[i] += 1;
1004 }
1005 }
1006 }
1007
1008 let checks: Vec<AiCheck> = CHECK_DEFS
1009 .iter()
1010 .enumerate()
1011 .map(|(i, (id, name, weight))| {
1012 let failures = failing_components[i];
1013 let detail = if component_details[i].is_empty() {
1014 None
1015 } else {
1016 Some(format!(
1017 "{}/{} components passed; {}",
1018 n - failures,
1019 n,
1020 component_details[i].join("; ")
1021 ))
1022 };
1023 AiCheck {
1024 id: (*id).to_string(),
1025 name: (*name).to_string(),
1026 passed: failures == 0,
1027 detail,
1028 weight: *weight / weight_sum,
1030 }
1031 })
1032 .collect();
1033
1034 let overall_score = ((total_weighted_score / n as f32) * 100.0).min(100.0);
1036
1037 let mut recommendations: Vec<Recommendation> = checks
1038 .iter()
1039 .zip(failing_components.iter())
1040 .filter(|(c, _)| !c.passed)
1041 .enumerate()
1042 .map(|(i, (chk, &affected_count))| Recommendation {
1043 priority: (i as u8 / 3) + 1,
1044 category: RecommendationCategory::Completeness,
1045 message: format!("[{}] {}", chk.id, chk.name),
1046 impact: chk.weight * 100.0,
1047 affected_count,
1048 })
1049 .collect();
1050
1051 recommendations.sort_by(|a, b| {
1052 a.priority.cmp(&b.priority).then_with(|| {
1053 b.impact
1054 .partial_cmp(&a.impact)
1055 .unwrap_or(std::cmp::Ordering::Equal)
1056 })
1057 });
1058
1059 let metrics = AiReadinessMetrics {
1060 ml_component_count: n,
1061 not_applicable: false,
1062 na_reason: None,
1063 checks,
1064 components_fully_documented,
1065 };
1066
1067 make_report(
1068 overall_score,
1069 QualityGrade::from_score(overall_score),
1070 recommendations,
1071 metrics,
1072 )
1073 }
1074
1075 fn apply_score_caps(
1077 &self,
1078 mut score: f32,
1079 lifecycle: &LifecycleMetrics,
1080 deps: &DependencyMetrics,
1081 hashes: &HashQualityMetrics,
1082 crypto: &CryptographyMetrics,
1083 total_components: usize,
1084 ) -> f32 {
1085 let is_security_profile =
1086 matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
1087
1088 if is_security_profile && lifecycle.eol_components > 0 {
1090 score = score.min(69.0);
1091 }
1092
1093 if deps.cycle_count > 0
1095 && matches!(
1096 self.profile,
1097 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
1098 )
1099 {
1100 score = score.min(89.0);
1101 }
1102
1103 if matches!(self.profile, ScoringProfile::Security)
1105 && total_components > 0
1106 && hashes.components_with_any_hash == 0
1107 {
1108 score = score.min(79.0);
1109 }
1110
1111 if matches!(self.profile, ScoringProfile::Security)
1113 && hashes.components_with_weak_only > 0
1114 && hashes.components_with_strong_hash == 0
1115 {
1116 score = score.min(89.0);
1117 }
1118
1119 if self.profile == ScoringProfile::Cbom && crypto.has_data() {
1121 if crypto.weak_algorithm_count > 0 {
1122 score = score.min(69.0);
1123 }
1124 if crypto.compromised_keys > 0 {
1125 score = score.min(79.0);
1126 }
1127 if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
1128 score = score.min(79.0);
1129 }
1130 }
1131
1132 score
1133 }
1134
1135 #[allow(clippy::too_many_arguments)]
1136 fn generate_recommendations(
1137 &self,
1138 completeness: &CompletenessMetrics,
1139 identifiers: &IdentifierMetrics,
1140 licenses: &LicenseMetrics,
1141 dependencies: &DependencyMetrics,
1142 hashes: &HashQualityMetrics,
1143 provenance: &ProvenanceMetrics,
1144 lifecycle: &LifecycleMetrics,
1145 auditability: &AuditabilityMetrics,
1146 compliance: &ComplianceResult,
1147 total_components: usize,
1148 ) -> Vec<Recommendation> {
1149 let mut recommendations = Vec::new();
1150
1151 if compliance.error_count > 0 {
1153 recommendations.push(Recommendation {
1154 priority: 1,
1155 category: RecommendationCategory::Compliance,
1156 message: format!(
1157 "Fix {} compliance error(s) to meet {} requirements",
1158 compliance.error_count,
1159 compliance.level.name()
1160 ),
1161 impact: 20.0,
1162 affected_count: compliance.error_count,
1163 });
1164 }
1165
1166 if lifecycle.eol_components > 0 {
1168 recommendations.push(Recommendation {
1169 priority: 1,
1170 category: RecommendationCategory::Lifecycle,
1171 message: format!(
1172 "{} component(s) have reached end-of-life — upgrade or replace",
1173 lifecycle.eol_components
1174 ),
1175 impact: 15.0,
1176 affected_count: lifecycle.eol_components,
1177 });
1178 }
1179
1180 let missing_versions = total_components
1182 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
1183 if missing_versions > 0 {
1184 recommendations.push(Recommendation {
1185 priority: 1,
1186 category: RecommendationCategory::Completeness,
1187 message: "Add version information to all components".to_string(),
1188 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
1189 affected_count: missing_versions,
1190 });
1191 }
1192
1193 if hashes.components_with_weak_only > 0 {
1195 recommendations.push(Recommendation {
1196 priority: 2,
1197 category: RecommendationCategory::Integrity,
1198 message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
1199 impact: 10.0,
1200 affected_count: hashes.components_with_weak_only,
1201 });
1202 }
1203
1204 if identifiers.missing_all_identifiers > 0 {
1206 recommendations.push(Recommendation {
1207 priority: 2,
1208 category: RecommendationCategory::Identifiers,
1209 message: "Add PURL or CPE identifiers to components".to_string(),
1210 impact: (identifiers.missing_all_identifiers as f32
1211 / total_components.max(1) as f32)
1212 * 20.0,
1213 affected_count: identifiers.missing_all_identifiers,
1214 });
1215 }
1216
1217 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
1219 if invalid_ids > 0 {
1220 recommendations.push(Recommendation {
1221 priority: 2,
1222 category: RecommendationCategory::Identifiers,
1223 message: "Fix malformed PURL/CPE identifiers".to_string(),
1224 impact: 10.0,
1225 affected_count: invalid_ids,
1226 });
1227 }
1228
1229 if !provenance.has_tool_creator {
1231 recommendations.push(Recommendation {
1232 priority: 2,
1233 category: RecommendationCategory::Provenance,
1234 message: "Add SBOM creation tool information".to_string(),
1235 impact: 8.0,
1236 affected_count: 0,
1237 });
1238 }
1239
1240 if dependencies.cycle_count > 0 {
1242 recommendations.push(Recommendation {
1243 priority: 3,
1244 category: RecommendationCategory::Dependencies,
1245 message: format!(
1246 "{} dependency cycle(s) detected — review dependency graph",
1247 dependencies.cycle_count
1248 ),
1249 impact: 10.0,
1250 affected_count: dependencies.cycle_count,
1251 });
1252 }
1253
1254 if let Some(level) = &dependencies.complexity_level {
1256 match level {
1257 super::metrics::ComplexityLevel::VeryHigh => {
1258 recommendations.push(Recommendation {
1259 priority: 2,
1260 category: RecommendationCategory::Dependencies,
1261 message:
1262 "Dependency structure is very complex — review for unnecessary transitive dependencies"
1263 .to_string(),
1264 impact: 8.0,
1265 affected_count: dependencies.total_dependencies,
1266 });
1267 }
1268 super::metrics::ComplexityLevel::High => {
1269 recommendations.push(Recommendation {
1270 priority: 3,
1271 category: RecommendationCategory::Dependencies,
1272 message:
1273 "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
1274 .to_string(),
1275 impact: 5.0,
1276 affected_count: dependencies.total_dependencies,
1277 });
1278 }
1279 _ => {}
1280 }
1281 }
1282
1283 let missing_licenses = total_components - licenses.with_declared;
1285 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
1286 {
1287 recommendations.push(Recommendation {
1288 priority: 3,
1289 category: RecommendationCategory::Licenses,
1290 message: "Add license information to components".to_string(),
1291 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
1292 affected_count: missing_licenses,
1293 });
1294 }
1295
1296 if licenses.noassertion_count > 0 {
1298 recommendations.push(Recommendation {
1299 priority: 3,
1300 category: RecommendationCategory::Licenses,
1301 message: "Replace NOASSERTION with actual license information".to_string(),
1302 impact: 5.0,
1303 affected_count: licenses.noassertion_count,
1304 });
1305 }
1306
1307 if total_components > 0 {
1310 let missing_vcs = total_components.saturating_sub(auditability.components_with_vcs);
1311 if missing_vcs > total_components / 2 {
1312 recommendations.push(Recommendation {
1313 priority: 3,
1314 category: RecommendationCategory::Provenance,
1315 message: "Add VCS (source repository) URLs to components".to_string(),
1316 impact: 5.0,
1317 affected_count: missing_vcs,
1318 });
1319 }
1320 }
1321
1322 if licenses.non_standard_licenses > 0 {
1324 recommendations.push(Recommendation {
1325 priority: 4,
1326 category: RecommendationCategory::Licenses,
1327 message: "Use SPDX license identifiers for better interoperability".to_string(),
1328 impact: 3.0,
1329 affected_count: licenses.non_standard_licenses,
1330 });
1331 }
1332
1333 if lifecycle.outdated_components > 0 {
1335 recommendations.push(Recommendation {
1336 priority: 4,
1337 category: RecommendationCategory::Lifecycle,
1338 message: format!(
1339 "{} component(s) are outdated — newer versions available",
1340 lifecycle.outdated_components
1341 ),
1342 impact: 5.0,
1343 affected_count: lifecycle.outdated_components,
1344 });
1345 }
1346
1347 if provenance.completeness_declaration == CompletenessDeclaration::Unknown
1349 && matches!(
1350 self.profile,
1351 ScoringProfile::Cra | ScoringProfile::Comprehensive
1352 )
1353 {
1354 recommendations.push(Recommendation {
1355 priority: 4,
1356 category: RecommendationCategory::Provenance,
1357 message: "Add compositions section with aggregate completeness declaration"
1358 .to_string(),
1359 impact: 5.0,
1360 affected_count: 0,
1361 });
1362 }
1363
1364 if total_components > 1 && dependencies.total_dependencies == 0 {
1366 recommendations.push(Recommendation {
1367 priority: 4,
1368 category: RecommendationCategory::Dependencies,
1369 message: "Add dependency relationships between components".to_string(),
1370 impact: 10.0,
1371 affected_count: total_components,
1372 });
1373 }
1374
1375 if dependencies.orphan_components > 1
1377 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
1378 {
1379 recommendations.push(Recommendation {
1380 priority: 4,
1381 category: RecommendationCategory::Dependencies,
1382 message: "Review orphan components that have no dependency relationships"
1383 .to_string(),
1384 impact: 5.0,
1385 affected_count: dependencies.orphan_components,
1386 });
1387 }
1388
1389 let missing_suppliers = total_components
1391 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
1392 if missing_suppliers > 0
1393 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
1394 {
1395 recommendations.push(Recommendation {
1396 priority: 5,
1397 category: RecommendationCategory::Completeness,
1398 message: "Add supplier information to components".to_string(),
1399 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
1400 affected_count: missing_suppliers,
1401 });
1402 }
1403
1404 let missing_hashes = total_components
1406 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
1407 if missing_hashes > 0
1408 && matches!(
1409 self.profile,
1410 ScoringProfile::Security | ScoringProfile::Comprehensive
1411 )
1412 {
1413 recommendations.push(Recommendation {
1414 priority: 5,
1415 category: RecommendationCategory::Integrity,
1416 message: "Add cryptographic hashes for integrity verification".to_string(),
1417 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
1418 affected_count: missing_hashes,
1419 });
1420 }
1421
1422 if !provenance.has_signature
1424 && matches!(
1425 self.profile,
1426 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
1427 )
1428 {
1429 recommendations.push(Recommendation {
1430 priority: 5,
1431 category: RecommendationCategory::Integrity,
1432 message: "Consider adding a digital signature to the SBOM".to_string(),
1433 impact: 3.0,
1434 affected_count: 0,
1435 });
1436 }
1437
1438 recommendations.sort_by(|a, b| {
1440 a.priority.cmp(&b.priority).then_with(|| {
1441 b.impact
1442 .partial_cmp(&a.impact)
1443 .unwrap_or(std::cmp::Ordering::Equal)
1444 })
1445 });
1446
1447 recommendations
1448 }
1449}
1450
1451impl Default for QualityScorer {
1452 fn default() -> Self {
1453 Self::new(ScoringProfile::Standard)
1454 }
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459 use super::*;
1460 use crate::model::{Component, ComponentType, DocumentMetadata, MlModelInfo};
1461 use serde_json::json;
1462
1463 #[test]
1464 fn test_grade_from_score() {
1465 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
1466 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
1467 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
1468 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
1469 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
1470 }
1471
1472 #[test]
1473 fn with_as_of_pins_the_embedded_compliance_clock() {
1474 use chrono::{TimeZone, Utc};
1480 let sbom = NormalizedSbom::default();
1481 let score_at = |ts: chrono::DateTime<chrono::Utc>| {
1482 QualityScorer::new(ScoringProfile::Cra)
1483 .with_cra_product_class(crate::model::CraProductClass::ImportantClass2)
1484 .with_as_of(ts)
1485 .score(&sbom)
1486 };
1487 let pre = score_at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
1488 let post = score_at(Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
1489 assert!(
1490 post.compliance.error_count > pre.compliance.error_count,
1491 "post-deadline run must escalate Art. 14 findings: pre={} post={}",
1492 pre.compliance.error_count,
1493 post.compliance.error_count
1494 );
1495 let pre2 = score_at(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
1497 assert_eq!(pre.compliance.error_count, pre2.compliance.error_count);
1498 assert_eq!(pre.compliance.is_compliant, pre2.compliance.is_compliant);
1499 }
1500
1501 #[test]
1502 fn test_scoring_profile_compliance_level() {
1503 assert_eq!(
1504 ScoringProfile::Minimal.compliance_level(),
1505 ComplianceLevel::Minimum
1506 );
1507 assert_eq!(
1508 ScoringProfile::Security.compliance_level(),
1509 ComplianceLevel::NtiaMinimum
1510 );
1511 assert_eq!(
1512 ScoringProfile::Comprehensive.compliance_level(),
1513 ComplianceLevel::Comprehensive
1514 );
1515 assert_eq!(
1516 ScoringProfile::AiReadiness.compliance_level(),
1517 ComplianceLevel::Comprehensive
1518 );
1519 }
1520
1521 #[test]
1522 fn test_scoring_weights_sum_to_one() {
1523 let profiles = [
1524 ScoringProfile::Minimal,
1525 ScoringProfile::Standard,
1526 ScoringProfile::Security,
1527 ScoringProfile::LicenseCompliance,
1528 ScoringProfile::Cra,
1529 ScoringProfile::Comprehensive,
1530 ScoringProfile::Cbom,
1531 ScoringProfile::AiReadiness,
1532 ];
1533 for profile in &profiles {
1534 let w = profile.weights();
1535 let sum: f32 = w.as_array().iter().sum();
1536 assert!(
1537 (sum - 1.0).abs() < 0.01,
1538 "{profile:?} weights sum to {sum}, expected 1.0"
1539 );
1540 }
1541 }
1542
1543 #[test]
1544 fn test_renormalize_all_available() {
1545 let w = ScoringProfile::Standard.weights();
1546 let available = [true; 8];
1547 let norm = w.renormalize(&available);
1548 let sum: f32 = norm.iter().sum();
1549 assert!((sum - 1.0).abs() < 0.001);
1550 }
1551
1552 #[test]
1553 fn test_renormalize_lifecycle_unavailable() {
1554 let w = ScoringProfile::Standard.weights();
1555 let mut available = [true; 8];
1556 available[7] = false; let norm = w.renormalize(&available);
1558 let sum: f32 = norm.iter().sum();
1559 assert!((sum - 1.0).abs() < 0.001);
1560 assert_eq!(norm[7], 0.0);
1561 }
1562
1563 #[test]
1564 fn test_scoring_engine_version() {
1565 assert_eq!(SCORING_ENGINE_VERSION, "2.1");
1566 }
1567
1568 #[test]
1569 fn cbom_hard_cap_weak_algorithms() {
1570 use crate::model::{
1571 AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
1572 CryptoPrimitive, CryptoProperties, NormalizedSbom,
1573 };
1574
1575 let mut sbom = NormalizedSbom::default();
1576 let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
1578 comp.component_type = ComponentType::Cryptographic;
1579 comp.crypto_properties = Some(
1580 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1581 AlgorithmProperties::new(CryptoPrimitive::Hash)
1582 .with_algorithm_family("MD5".to_string())
1583 .with_nist_quantum_security_level(0),
1584 ),
1585 );
1586 sbom.components
1587 .insert(CanonicalId::from_name_version("md5", None), comp);
1588
1589 let scorer = QualityScorer::new(ScoringProfile::Cbom);
1590 let report = scorer.score(&sbom);
1591 assert!(
1593 report.overall_score <= 69.0,
1594 "weak algo should cap at D, got {}",
1595 report.overall_score
1596 );
1597 }
1598
1599 #[test]
1604 fn cbom_zero_algorithms_reweights_pqc_slot() {
1605 use crate::model::{
1606 CanonicalId, Component, ComponentType, CryptoAssetType, CryptoProperties,
1607 NormalizedSbom,
1608 };
1609
1610 let mut sbom = NormalizedSbom::default();
1611 let mut comp = Component::new("tls-cert".to_string(), "cert-ref".to_string());
1612 comp.component_type = ComponentType::Cryptographic;
1613 comp.crypto_properties = Some(CryptoProperties::new(CryptoAssetType::Certificate));
1614 sbom.components
1615 .insert(CanonicalId::from_name_version("tls-cert", None), comp);
1616
1617 let report = QualityScorer::new(ScoringProfile::Cbom).score(&sbom);
1618 let cm = &report.cryptography_metrics;
1619 assert!(cm.has_data());
1620 assert_eq!(cm.algorithms_count, 0);
1621 assert!(
1622 cm.pqc_readiness_score().is_none(),
1623 "no algorithms → PQC N/A"
1624 );
1625
1626 let w = ScoringProfile::Cbom.weights().as_array();
1629 let scores = [
1630 cm.crypto_completeness_score(),
1631 cm.crypto_identifier_score(),
1632 cm.algorithm_strength_score(),
1633 cm.crypto_dependency_score(),
1634 cm.crypto_lifecycle_score(),
1635 0.0, report.provenance_score,
1637 report.license_score,
1638 ];
1639 let available_weight: f32 = w
1640 .iter()
1641 .enumerate()
1642 .filter(|&(i, _)| i != 5)
1643 .map(|(_, wt)| wt)
1644 .sum();
1645 let expected: f32 = scores
1646 .iter()
1647 .zip(w.iter())
1648 .enumerate()
1649 .filter(|&(i, _)| i != 5)
1650 .map(|(_, (s, wt))| s * (wt / available_weight))
1651 .sum();
1652 assert!(
1653 (report.overall_score - expected.min(100.0)).abs() < 0.01,
1654 "overall {} != reweighted aggregate {}",
1655 report.overall_score,
1656 expected
1657 );
1658
1659 let inflated: f32 = scores
1662 .iter()
1663 .zip(w.iter())
1664 .enumerate()
1665 .map(|(i, (s, wt))| if i == 5 { 100.0 * wt } else { s * wt })
1666 .sum();
1667 assert!(
1668 (report.overall_score - inflated).abs() > 0.5,
1669 "overall {} still matches the vacuous-100 aggregate {}",
1670 report.overall_score,
1671 inflated
1672 );
1673 }
1674
1675 fn ml_component(bom_ref: &str, name: &str, ml: MlModelInfo, raw: Value) -> Component {
1676 let mut component =
1677 Component::new(name.to_string(), bom_ref.to_string()).with_version("1.0.0".to_string());
1678 component.component_type = ComponentType::MachineLearningModel;
1679 component.ml_model = Some(ml);
1680 component.extensions.raw = Some(raw);
1681 component
1682 }
1683
1684 #[test]
1685 fn test_ai_readiness_not_applicable_without_ml_components() {
1686 let sbom = NormalizedSbom::new(DocumentMetadata::default());
1687 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1688 let metrics = report
1689 .ai_readiness_metrics
1690 .expect("AI readiness metrics should be present");
1691 assert!(metrics.is_not_applicable());
1692 assert_eq!(metrics.ml_component_count, 0);
1693 assert!(metrics.checks.is_empty());
1694 }
1695
1696 #[test]
1702 fn test_ai_readiness_scores_mistyped_model_with_ml_metadata() {
1703 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1704 let mut component = Component::new("sentiment-model".to_string(), "ml-1".to_string())
1705 .with_version("1.0.0".to_string());
1706 component.component_type = ComponentType::Application;
1707 component.ml_model = Some(MlModelInfo::default());
1708 sbom.add_component(component);
1709
1710 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1711 let metrics = report
1712 .ai_readiness_metrics
1713 .expect("AI readiness metrics should be present");
1714 assert!(
1715 !metrics.is_not_applicable(),
1716 "ML-model metadata must make the profile applicable regardless of the declared type"
1717 );
1718 assert_eq!(metrics.ml_component_count, 1);
1719 assert_eq!(metrics.checks.len(), 11);
1722 assert!(metrics.checks.iter().all(|c| !c.passed));
1723 assert_eq!(report.grade, QualityGrade::F);
1724 }
1725
1726 #[test]
1731 fn test_ai_readiness_scores_untyped_huggingface_suspect() {
1732 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1733 let hf = Component::new("bert-base-uncased".to_string(), "hf-1".to_string())
1734 .with_version("1.0.0".to_string())
1735 .with_purl("pkg:huggingface/google-bert/bert-base-uncased@1.0.0".to_string());
1736 sbom.add_component(hf);
1737
1738 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1739 let metrics = report
1740 .ai_readiness_metrics
1741 .expect("AI readiness metrics should be present");
1742 assert!(
1743 !metrics.is_not_applicable(),
1744 "an ML-looking suspect must keep the AI-readiness profile applicable"
1745 );
1746 assert_eq!(metrics.ml_component_count, 1);
1747 assert_eq!(report.grade, QualityGrade::F);
1748 }
1749
1750 #[test]
1755 fn test_ai_readiness_does_not_score_hf_dataset_with_evidence() {
1756 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1757 let mut dataset = Component::new("imdb".to_string(), "ds-1".to_string())
1758 .with_version("1.0.0".to_string())
1759 .with_purl("pkg:huggingface/datasets/imdb@1.0.0".to_string());
1760 dataset.component_type = ComponentType::Data;
1761 dataset.dataset = Some(crate::model::DatasetInfo::default());
1762 sbom.add_component(dataset);
1763 sbom.add_component(
1765 Component::new("express".to_string(), "lib-1".to_string())
1766 .with_purl("pkg:npm/express@4.19.2".to_string()),
1767 );
1768
1769 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1770 let metrics = report
1771 .ai_readiness_metrics
1772 .expect("AI readiness metrics should be present");
1773 assert!(
1774 metrics.is_not_applicable(),
1775 "a documented dataset must not be scored as an ML model"
1776 );
1777 assert_eq!(metrics.ml_component_count, 0);
1778 }
1779
1780 #[test]
1781 fn test_ai_readiness_reads_nested_model_card_extensions() {
1782 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1783 let ml = MlModelInfo {
1784 architecture_family: Some("transformer".to_string()),
1785 training_datasets: vec![crate::model::DatasetRef {
1786 reference: None,
1787 name: Some("wikipedia-2.5B".to_string()),
1788 purl: None,
1789 }],
1790 energy_kwh_training: Some(1500.0),
1791 model_card_url: Some("https://example.test/model-card".to_string()),
1792 limitations: Some("Only validated for English text".to_string()),
1793 ..MlModelInfo::default()
1794 };
1795 let raw = json!({
1796 "mlModel": {
1797 "modelCard": {
1798 "quantitativeAnalysis": {
1799 "performanceMetrics": [{ "type": "accuracy", "value": 0.97 }]
1800 },
1801 "considerations": {
1802 "fairnessConsiderations": ["Assessed on demographic parity"],
1803 "useCases": ["Document classification"],
1804 "ethicalConsiderations": ["Human review required for sensitive domains"]
1805 }
1806 }
1807 }
1808 });
1809 let mut component = ml_component("ml-1", "bert-base", ml, raw);
1810 component.hashes.push(crate::model::Hash::new(
1812 crate::model::HashAlgorithm::Sha256,
1813 "a".repeat(64),
1814 ));
1815 component
1817 .vulnerabilities
1818 .push(crate::model::VulnerabilityRef::new(
1819 "CVE-2024-0001".to_string(),
1820 crate::model::VulnerabilitySource::Cve,
1821 ));
1822 sbom.add_component(component);
1823
1824 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1825 let metrics = report
1826 .ai_readiness_metrics
1827 .expect("AI readiness metrics should be present");
1828 assert!(!metrics.is_not_applicable());
1829 for check in &metrics.checks {
1831 assert!(check.passed, "expected {} to pass", check.id);
1832 }
1833 assert_eq!(metrics.checks.len(), 11, "AI-001..AI-011 are all reported");
1834 let weight_total: f32 = metrics.checks.iter().map(|c| c.weight).sum();
1836 assert!(
1837 (weight_total - 1.0).abs() < 0.001,
1838 "renormalized weights must sum to 1.0, got {weight_total}"
1839 );
1840 assert_eq!(metrics.components_fully_documented, 1);
1841 assert!((report.overall_score - 100.0).abs() < 0.01);
1842 assert_eq!(report.grade, QualityGrade::A);
1843 }
1844
1845 #[test]
1846 fn test_ai_readiness_fails_check_when_any_model_is_missing_it() {
1847 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1848 let complete_ml = MlModelInfo {
1849 architecture_family: Some("transformer".to_string()),
1850 training_datasets: vec![crate::model::DatasetRef {
1851 reference: None,
1852 name: Some("dataset".to_string()),
1853 purl: None,
1854 }],
1855 energy_kwh_training: Some(10.0),
1856 model_card_url: Some("https://example.test/model-card".to_string()),
1857 limitations: Some("Only validated for English text".to_string()),
1858 ..MlModelInfo::default()
1859 };
1860 let complete_raw = json!({
1861 "mlModel": { "modelCard": {
1862 "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.98 }] },
1863 "considerations": {
1864 "fairnessConsiderations": ["Reviewed"],
1865 "useCases": ["Classification"],
1866 "ethicalConsiderations": ["Human review required"]
1867 }
1868 }}
1869 });
1870 sbom.add_component(ml_component(
1871 "ml-1",
1872 "complete-model",
1873 complete_ml.clone(),
1874 complete_raw,
1875 ));
1876
1877 let incomplete_raw = json!({
1879 "mlModel": { "modelCard": {
1880 "quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.94 }] },
1881 "considerations": {
1882 "useCases": ["Classification"],
1883 "ethicalConsiderations": ["Human review required"]
1884 }
1885 }}
1886 });
1887 sbom.add_component(ml_component(
1888 "ml-2",
1889 "incomplete-model",
1890 complete_ml,
1891 incomplete_raw,
1892 ));
1893
1894 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1895 let metrics = report
1896 .ai_readiness_metrics
1897 .expect("AI readiness metrics should be present");
1898 let fairness = metrics
1899 .checks
1900 .iter()
1901 .find(|c| c.id == "AI-005")
1902 .expect("AI-005 should be present");
1903 assert!(
1904 !fairness.passed,
1905 "AI-005 should fail when any model is missing fairness data"
1906 );
1907 assert!(
1908 fairness
1909 .detail
1910 .as_deref()
1911 .unwrap_or_default()
1912 .contains("1/2 components passed")
1913 );
1914 let rec = report
1915 .recommendations
1916 .iter()
1917 .find(|r| r.message.contains("AI-005"))
1918 .expect("missing fairness recommendation");
1919 assert_eq!(rec.affected_count, 1);
1920 }
1921
1922 #[test]
1923 fn test_ai_010_weight_hash_integrity_check() {
1924 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1925
1926 let bare = ml_component("ml-1", "no-hash", MlModelInfo::default(), json!({}));
1928 sbom.add_component(bare);
1929
1930 let mut hashed = ml_component("ml-2", "with-hash", MlModelInfo::default(), json!({}));
1931 hashed.hashes.push(crate::model::Hash::new(
1932 crate::model::HashAlgorithm::Sha256,
1933 "b".repeat(64),
1934 ));
1935 sbom.add_component(hashed);
1936
1937 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1938 let metrics = report
1939 .ai_readiness_metrics
1940 .expect("AI readiness metrics should be present");
1941
1942 let ai010 = metrics
1943 .checks
1944 .iter()
1945 .find(|c| c.id == "AI-010")
1946 .expect("AI-010 should be present");
1947 assert!(
1949 !ai010.passed,
1950 "AI-010 should fail when any model is missing weight hashes"
1951 );
1952 assert!(
1953 ai010
1954 .detail
1955 .as_deref()
1956 .unwrap_or_default()
1957 .contains("1/2 components passed"),
1958 "AI-010 detail should report 1/2 models passing"
1959 );
1960 }
1961
1962 #[test]
1963 fn test_ai_011_exploitability_reference_check() {
1964 use crate::model::{
1965 ExternalRefType, ExternalReference, VulnerabilityRef, VulnerabilitySource,
1966 };
1967
1968 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1969
1970 let mut with_vuln = ml_component("ml-1", "with-vuln", MlModelInfo::default(), json!({}));
1972 with_vuln.vulnerabilities.push(VulnerabilityRef::new(
1973 "CVE-2024-1234".to_string(),
1974 VulnerabilitySource::Cve,
1975 ));
1976 sbom.add_component(with_vuln);
1977
1978 let mut with_advisory =
1980 ml_component("ml-2", "with-advisory", MlModelInfo::default(), json!({}));
1981 with_advisory.external_refs.push(ExternalReference {
1982 ref_type: ExternalRefType::Advisories,
1983 url: "https://example.test/advisory".to_string(),
1984 comment: None,
1985 hashes: Vec::new(),
1986 });
1987 sbom.add_component(with_advisory);
1988
1989 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
1990 let metrics = report
1991 .ai_readiness_metrics
1992 .expect("AI readiness metrics should be present");
1993 let ai011 = metrics
1994 .checks
1995 .iter()
1996 .find(|c| c.id == "AI-011")
1997 .expect("AI-011 should be present");
1998 assert!(
1999 ai011.passed,
2000 "AI-011 should pass when every model carries a vuln or advisory reference"
2001 );
2002 }
2003
2004 #[test]
2005 fn test_ai_011_fails_without_exploitability_reference() {
2006 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2007 sbom.add_component(ml_component(
2009 "ml-1",
2010 "no-refs",
2011 MlModelInfo::default(),
2012 json!({}),
2013 ));
2014
2015 let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
2016 let metrics = report
2017 .ai_readiness_metrics
2018 .expect("AI readiness metrics should be present");
2019 let ai011 = metrics
2020 .checks
2021 .iter()
2022 .find(|c| c.id == "AI-011")
2023 .expect("AI-011 should be present");
2024 assert!(
2025 !ai011.passed,
2026 "AI-011 should fail when a model has no exploitability/advisory reference"
2027 );
2028 }
2029}