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)]
59#[non_exhaustive]
60pub enum ScoringProfile {
61 Minimal,
63 Standard,
65 Security,
67 LicenseCompliance,
69 Cra,
71 BsiTr03183_2,
74 Comprehensive,
76 Cbom,
78 AiReadiness,
80}
81
82impl ScoringProfile {
83 #[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 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 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 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 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#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[non_exhaustive]
263pub enum QualityGrade {
264 A,
266 B,
268 C,
270 D,
272 F,
274}
275
276impl QualityGrade {
277 #[must_use]
279 pub const fn from_score(score: f32) -> Self {
280 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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct Recommendation {
325 pub priority: u8,
327 pub category: RecommendationCategory,
329 pub message: String,
331 pub impact: f32,
333 pub affected_count: usize,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339#[non_exhaustive]
340pub struct AiCheck {
341 pub id: String,
343 pub name: String,
345 pub passed: bool,
347 pub detail: Option<String>,
349 pub weight: f32,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355#[non_exhaustive]
356pub struct AiReadinessMetrics {
357 pub ml_component_count: usize,
359 pub not_applicable: bool,
361 pub na_reason: Option<String>,
363 pub checks: Vec<AiCheck>,
365 pub components_fully_documented: usize,
367}
368
369impl AiReadinessMetrics {
370 #[must_use]
372 pub const fn is_not_applicable(&self) -> bool {
373 self.not_applicable
374 }
375}
376
377#[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#[derive(Debug, Clone, Serialize, Deserialize)]
411#[must_use]
412#[non_exhaustive]
413pub struct QualityReport {
414 pub scoring_engine_version: String,
416 pub overall_score: f32,
418 pub grade: QualityGrade,
420 pub profile: ScoringProfile,
422
423 pub completeness_score: f32,
426 pub identifier_score: f32,
428 pub license_score: f32,
430 pub vulnerability_score: Option<f32>,
432 pub dependency_score: f32,
434 pub integrity_score: f32,
436 pub provenance_score: f32,
438 pub lifecycle_score: Option<f32>,
440
441 pub completeness_metrics: CompletenessMetrics,
444 pub identifier_metrics: IdentifierMetrics,
446 pub license_metrics: LicenseMetrics,
448 pub vulnerability_metrics: VulnerabilityMetrics,
450 pub dependency_metrics: DependencyMetrics,
452 pub hash_quality_metrics: HashQualityMetrics,
454 pub provenance_metrics: ProvenanceMetrics,
456 pub auditability_metrics: AuditabilityMetrics,
458 pub lifecycle_metrics: LifecycleMetrics,
460 pub cryptography_score: Option<f32>,
462 pub cryptography_metrics: CryptographyMetrics,
464
465 pub compliance: ComplianceResult,
467 pub recommendations: Vec<Recommendation>,
469 pub ai_readiness_metrics: Option<AiReadinessMetrics>,
471}
472
473#[derive(Debug, Clone)]
475pub struct QualityScorer {
476 profile: ScoringProfile,
478 completeness_weights: CompletenessWeights,
480 cra_sidecar: Option<crate::model::CraSidecarMetadata>,
484 cra_product_class: Option<crate::model::CraProductClass>,
487}
488
489impl QualityScorer {
490 #[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 #[must_use]
503 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
504 self.completeness_weights = weights;
505 self
506 }
507
508 #[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 #[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 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
526 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 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 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 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 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], [
568 cm.crypto_completeness_score(), cm.crypto_identifier_score(), cm.algorithm_strength_score(), cm.crypto_dependency_score(), cm.crypto_lifecycle_score(), cm.pqc_readiness_score(), provenance_score, license_score, ],
577 )
578 } else {
579 let vuln_available = vulnerability_score.is_some();
581 let lifecycle_available = lifecycle_score.is_some();
582 (
583 [
584 true, true, true, vuln_available, true, true, true, lifecycle_available, ],
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 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 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 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 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 fn score_ai_readiness(&self, sbom: &NormalizedSbom) -> QualityReport {
686 use crate::model::ComponentType;
687
688 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 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", "Exploitability/advisory reference present", 0.12),
773 ];
774
775 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 ml.and_then(|m| m.model_card_url.as_ref()).is_some(),
793 ml.and_then(|m| m.architecture_family.as_ref()).is_some(),
795 ml.is_some_and(|m| !m.training_datasets.is_empty()),
797 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 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 "/modelCard/considerations/fairnessConsiderations",
818 "/mlModel/modelCard/considerations/fairnessConsiderations",
819 "/mlModel/considerations/fairnessConsiderations",
820 ],
821 ),
822 ml.and_then(|m| m.energy_kwh_training).is_some(),
824 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 ml.and_then(|m| m.limitations.as_ref()).is_some(),
836 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 !component.hashes.is_empty(),
851 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 weight: *weight / weight_sum,
902 }
903 })
904 .collect();
905
906 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 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 if is_security_profile && lifecycle.eol_components > 0 {
962 score = score.min(69.0);
963 }
964
965 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 component.hashes.push(crate::model::Hash::new(
1495 crate::model::HashAlgorithm::Sha256,
1496 "a".repeat(64),
1497 ));
1498 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 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 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 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 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 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 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 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 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}