use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
use super::metrics::{
AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, CryptographyMetrics,
DependencyMetrics, HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics,
ProvenanceMetrics, VulnerabilityMetrics,
};
pub const SCORING_ENGINE_VERSION: &str = "2.1";
fn has_non_empty_pointer(raw: Option<&Value>, pointers: &[&str]) -> bool {
pointers
.iter()
.filter_map(|pointer| raw.and_then(|value| value.pointer(pointer)))
.any(|value| match value {
Value::Null => false,
Value::Array(items) => !items.is_empty(),
Value::Object(entries) => !entries.is_empty(),
Value::String(text) => !text.trim().is_empty(),
_ => true,
})
}
fn ml_has_exploitability_reference(component: &crate::model::Component) -> bool {
use crate::model::ExternalRefType;
if !component.vulnerabilities.is_empty() {
return true;
}
component.external_refs.iter().any(|r| {
matches!(
r.ref_type,
ExternalRefType::Advisories
| ExternalRefType::SecurityContact
| ExternalRefType::VulnerabilityAssertion
| ExternalRefType::ExploitabilityStatement
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ScoringProfile {
Minimal,
Standard,
Security,
LicenseCompliance,
Cra,
BsiTr03183_2,
Comprehensive,
Cbom,
AiReadiness,
}
impl ScoringProfile {
#[must_use]
pub const fn compliance_level(&self) -> ComplianceLevel {
match self {
Self::Minimal => ComplianceLevel::Minimum,
Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
Self::Security => ComplianceLevel::NtiaMinimum,
Self::Cra => ComplianceLevel::CraPhase2,
Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
Self::Comprehensive => ComplianceLevel::Comprehensive,
Self::Cbom => ComplianceLevel::Comprehensive,
Self::AiReadiness => ComplianceLevel::Comprehensive,
}
}
const fn weights(self) -> ScoringWeights {
match self {
Self::Minimal => ScoringWeights {
completeness: 0.35,
identifiers: 0.20,
licenses: 0.10,
vulnerabilities: 0.05,
dependencies: 0.10,
integrity: 0.05,
provenance: 0.10,
lifecycle: 0.05,
},
Self::Standard => ScoringWeights {
completeness: 0.25,
identifiers: 0.20,
licenses: 0.12,
vulnerabilities: 0.08,
dependencies: 0.10,
integrity: 0.08,
provenance: 0.10,
lifecycle: 0.07,
},
Self::Security => ScoringWeights {
completeness: 0.12,
identifiers: 0.18,
licenses: 0.05,
vulnerabilities: 0.20,
dependencies: 0.10,
integrity: 0.15,
provenance: 0.10,
lifecycle: 0.10,
},
Self::LicenseCompliance => ScoringWeights {
completeness: 0.15,
identifiers: 0.12,
licenses: 0.35,
vulnerabilities: 0.05,
dependencies: 0.10,
integrity: 0.05,
provenance: 0.10,
lifecycle: 0.08,
},
Self::Cra => ScoringWeights {
completeness: 0.12,
identifiers: 0.18,
licenses: 0.08,
vulnerabilities: 0.15,
dependencies: 0.12,
integrity: 0.12,
provenance: 0.15,
lifecycle: 0.08,
},
Self::BsiTr03183_2 => ScoringWeights {
completeness: 0.10,
identifiers: 0.22,
licenses: 0.08,
vulnerabilities: 0.10,
dependencies: 0.12,
integrity: 0.18,
provenance: 0.12,
lifecycle: 0.08,
},
Self::Comprehensive => ScoringWeights {
completeness: 0.15,
identifiers: 0.13,
licenses: 0.13,
vulnerabilities: 0.10,
dependencies: 0.12,
integrity: 0.12,
provenance: 0.13,
lifecycle: 0.12,
},
Self::Cbom => ScoringWeights {
completeness: 0.15,
identifiers: 0.15,
licenses: 0.22,
vulnerabilities: 0.10,
dependencies: 0.13,
integrity: 0.15,
provenance: 0.08,
lifecycle: 0.02,
},
Self::AiReadiness => ScoringWeights {
completeness: 0.25,
identifiers: 0.15,
licenses: 0.15,
vulnerabilities: 0.10,
dependencies: 0.10,
integrity: 0.08,
provenance: 0.10,
lifecycle: 0.07,
},
}
}
}
#[derive(Debug, Clone)]
struct ScoringWeights {
completeness: f32,
identifiers: f32,
licenses: f32,
vulnerabilities: f32,
dependencies: f32,
integrity: f32,
provenance: f32,
lifecycle: f32,
}
impl ScoringWeights {
fn as_array(&self) -> [f32; 8] {
[
self.completeness,
self.identifiers,
self.licenses,
self.vulnerabilities,
self.dependencies,
self.integrity,
self.provenance,
self.lifecycle,
]
}
fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
let raw = self.as_array();
let total_available: f32 = raw
.iter()
.zip(available)
.filter(|&(_, a)| *a)
.map(|(w, _)| w)
.sum();
if total_available <= 0.0 {
return [0.0; 8];
}
let scale = 1.0 / total_available;
let mut result = [0.0_f32; 8];
for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
result[i] = if avail { w * scale } else { 0.0 };
}
result
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum QualityGrade {
A,
B,
C,
D,
F,
}
impl QualityGrade {
#[must_use]
pub const fn from_score(score: f32) -> Self {
let clamped = if score > 100.0 {
100
} else if score >= 0.0 {
score as u32
} else {
0
};
match clamped {
90..=100 => Self::A,
80..=89 => Self::B,
70..=79 => Self::C,
60..=69 => Self::D,
_ => Self::F,
}
}
#[must_use]
pub const fn letter(&self) -> &'static str {
match self {
Self::A => "A",
Self::B => "B",
Self::C => "C",
Self::D => "D",
Self::F => "F",
}
}
#[must_use]
pub const fn description(&self) -> &'static str {
match self {
Self::A => "Excellent",
Self::B => "Good",
Self::C => "Fair",
Self::D => "Poor",
Self::F => "Failing",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
pub priority: u8,
pub category: RecommendationCategory,
pub message: String,
pub impact: f32,
pub affected_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AiCheck {
pub id: String,
pub name: String,
pub passed: bool,
pub detail: Option<String>,
pub weight: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AiReadinessMetrics {
pub ml_component_count: usize,
pub not_applicable: bool,
pub na_reason: Option<String>,
pub checks: Vec<AiCheck>,
pub components_fully_documented: usize,
}
impl AiReadinessMetrics {
#[must_use]
pub const fn is_not_applicable(&self) -> bool {
self.not_applicable
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RecommendationCategory {
Completeness,
Identifiers,
Licenses,
Vulnerabilities,
Dependencies,
Compliance,
Integrity,
Provenance,
Lifecycle,
}
impl RecommendationCategory {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Completeness => "Completeness",
Self::Identifiers => "Identifiers",
Self::Licenses => "Licenses",
Self::Vulnerabilities => "Vulnerabilities",
Self::Dependencies => "Dependencies",
Self::Compliance => "Compliance",
Self::Integrity => "Integrity",
Self::Provenance => "Provenance",
Self::Lifecycle => "Lifecycle",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[must_use]
#[non_exhaustive]
pub struct QualityReport {
pub scoring_engine_version: String,
pub overall_score: f32,
pub grade: QualityGrade,
pub profile: ScoringProfile,
pub completeness_score: f32,
pub identifier_score: f32,
pub license_score: f32,
pub vulnerability_score: Option<f32>,
pub dependency_score: f32,
pub integrity_score: f32,
pub provenance_score: f32,
pub lifecycle_score: Option<f32>,
pub completeness_metrics: CompletenessMetrics,
pub identifier_metrics: IdentifierMetrics,
pub license_metrics: LicenseMetrics,
pub vulnerability_metrics: VulnerabilityMetrics,
pub dependency_metrics: DependencyMetrics,
pub hash_quality_metrics: HashQualityMetrics,
pub provenance_metrics: ProvenanceMetrics,
pub auditability_metrics: AuditabilityMetrics,
pub lifecycle_metrics: LifecycleMetrics,
pub cryptography_score: Option<f32>,
pub cryptography_metrics: CryptographyMetrics,
pub compliance: ComplianceResult,
pub recommendations: Vec<Recommendation>,
pub ai_readiness_metrics: Option<AiReadinessMetrics>,
}
#[derive(Debug, Clone)]
pub struct QualityScorer {
profile: ScoringProfile,
completeness_weights: CompletenessWeights,
cra_sidecar: Option<crate::model::CraSidecarMetadata>,
cra_product_class: Option<crate::model::CraProductClass>,
}
impl QualityScorer {
#[must_use]
pub fn new(profile: ScoringProfile) -> Self {
Self {
profile,
completeness_weights: CompletenessWeights::default(),
cra_sidecar: None,
cra_product_class: None,
}
}
#[must_use]
pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
self.completeness_weights = weights;
self
}
#[must_use]
pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
self.cra_sidecar = Some(sidecar);
self
}
#[must_use]
pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
self.cra_product_class = Some(class);
self
}
pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
if self.profile == ScoringProfile::AiReadiness {
return self.score_ai_readiness(sbom);
}
let total_components = sbom.components.len();
let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
let license_metrics = LicenseMetrics::from_sbom(sbom);
let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
let dependency_metrics = DependencyMetrics::from_sbom(sbom);
let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
let identifier_score = identifier_metrics.quality_score(total_components);
let license_score = license_metrics.quality_score(total_components);
let vulnerability_score = vulnerability_metrics.documentation_score();
let dependency_score = dependency_metrics.quality_score(total_components);
let integrity_score = hash_quality_metrics.quality_score(total_components);
let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
let auditability_raw = auditability_metrics.quality_score(total_components);
let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
let lifecycle_score = lifecycle_metrics.quality_score();
let cryptography_score = cryptography_metrics.quality_score();
let is_cbom = self.profile == ScoringProfile::Cbom;
let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
let cm = &cryptography_metrics;
(
[true; 8], [
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, ],
)
} else {
let vuln_available = vulnerability_score.is_some();
let lifecycle_available = lifecycle_score.is_some();
(
[
true, true, true, vuln_available, true, true, true, lifecycle_available, ],
[
completeness_score,
identifier_score,
license_score,
vulnerability_score.unwrap_or(0.0),
dependency_score,
integrity_score,
provenance_score,
lifecycle_score.unwrap_or(0.0),
],
)
};
let weights = self.profile.weights();
let norm = weights.renormalize(&available);
let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
overall_score = overall_score.min(100.0);
overall_score = self.apply_score_caps(
overall_score,
&lifecycle_metrics,
&dependency_metrics,
&hash_quality_metrics,
&cryptography_metrics,
total_components,
);
let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
if let Some(sc) = self.cra_sidecar.clone() {
compliance_checker = compliance_checker.with_sidecar(sc);
}
if let Some(c) = self.cra_product_class {
compliance_checker = compliance_checker.with_product_class(c);
}
let compliance = compliance_checker.check(sbom);
let recommendations = self.generate_recommendations(
&completeness_metrics,
&identifier_metrics,
&license_metrics,
&dependency_metrics,
&hash_quality_metrics,
&provenance_metrics,
&lifecycle_metrics,
&compliance,
total_components,
);
QualityReport {
scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
overall_score,
grade: QualityGrade::from_score(overall_score),
profile: self.profile,
completeness_score,
identifier_score,
license_score,
vulnerability_score,
dependency_score,
integrity_score,
provenance_score,
lifecycle_score,
completeness_metrics,
identifier_metrics,
license_metrics,
vulnerability_metrics,
dependency_metrics,
hash_quality_metrics,
provenance_metrics,
auditability_metrics,
lifecycle_metrics,
cryptography_score,
cryptography_metrics,
compliance,
recommendations,
ai_readiness_metrics: None,
}
}
fn score_ai_readiness(&self, sbom: &NormalizedSbom) -> QualityReport {
use crate::model::ComponentType;
let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
let license_metrics = LicenseMetrics::from_sbom(sbom);
let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
let dependency_metrics = DependencyMetrics::from_sbom(sbom);
let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
let compliance = ComplianceChecker::new(self.profile.compliance_level()).check(sbom);
let make_report = |overall_score: f32,
grade: QualityGrade,
recommendations: Vec<Recommendation>,
metrics: AiReadinessMetrics| QualityReport {
scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
overall_score,
grade,
profile: self.profile,
completeness_score: 0.0,
identifier_score: 0.0,
license_score: 0.0,
vulnerability_score: None,
dependency_score: 0.0,
integrity_score: 0.0,
provenance_score: 0.0,
lifecycle_score: None,
completeness_metrics: completeness_metrics.clone(),
identifier_metrics: identifier_metrics.clone(),
license_metrics: license_metrics.clone(),
vulnerability_metrics: vulnerability_metrics.clone(),
dependency_metrics: dependency_metrics.clone(),
hash_quality_metrics: hash_quality_metrics.clone(),
provenance_metrics: provenance_metrics.clone(),
auditability_metrics: auditability_metrics.clone(),
lifecycle_metrics: lifecycle_metrics.clone(),
cryptography_score: None,
cryptography_metrics: CryptographyMetrics::default(),
compliance: compliance.clone(),
recommendations,
ai_readiness_metrics: Some(metrics),
};
let ml_components: Vec<_> = sbom
.components
.values()
.filter(|c| c.component_type == ComponentType::MachineLearningModel)
.collect();
if ml_components.is_empty() {
let metrics = AiReadinessMetrics {
ml_component_count: 0,
not_applicable: true,
na_reason: Some(
"No machine-learning-model components found in this SBOM".to_string(),
),
checks: Vec::new(),
components_fully_documented: 0,
};
return make_report(0.0, QualityGrade::F, Vec::new(), metrics);
}
const CHECK_DEFS: [(&str, &str, f32); 11] = [
("AI-001", "Model card URL present", 0.15),
("AI-002", "Architecture family declared", 0.12),
("AI-003", "Training datasets referenced", 0.12),
("AI-004", "Quantitative analysis present", 0.12),
("AI-005", "Fairness assessments included", 0.11),
("AI-006", "Energy consumption disclosed", 0.10),
("AI-007", "Use-cases documented", 0.10),
("AI-008", "Known limitations stated", 0.09),
("AI-009", "Ethical considerations present", 0.09),
("AI-010", "Model weight hashes present", 0.12),
("AI-011", "Exploitability/advisory reference present", 0.12),
];
let weight_sum: f32 = CHECK_DEFS.iter().map(|(_, _, w)| *w).sum();
let n = ml_components.len();
let mut total_weighted_score = 0.0_f32;
let mut components_fully_documented = 0_usize;
let mut component_details: Vec<Vec<String>> = vec![Vec::new(); CHECK_DEFS.len()];
let mut failing_components = vec![0_usize; CHECK_DEFS.len()];
for component in &ml_components {
let ml = component.ml_model.as_ref();
let raw = component.extensions.raw.as_ref();
let results: [bool; 11] = [
ml.and_then(|m| m.model_card_url.as_ref()).is_some(),
ml.and_then(|m| m.architecture_family.as_ref()).is_some(),
ml.is_some_and(|m| !m.training_datasets.is_empty()),
ml.is_some_and(|m| !m.performance_metrics.is_empty())
|| has_non_empty_pointer(
raw,
&[
"/modelCard/quantitativeAnalysis",
"/mlModel/modelCard/quantitativeAnalysis",
],
),
ml.is_some_and(|m| !m.fairness.is_empty())
|| has_non_empty_pointer(
raw,
&[
"/modelCard/considerations/fairnessAssessments",
"/mlModel/modelCard/considerations/fairnessAssessments",
"/mlModel/considerations/fairnessAssessments",
"/modelCard/considerations/fairnessConsiderations",
"/mlModel/modelCard/considerations/fairnessConsiderations",
"/mlModel/considerations/fairnessConsiderations",
],
),
ml.and_then(|m| m.energy_kwh_training).is_some(),
ml.is_some_and(|m| !m.use_cases.is_empty())
|| has_non_empty_pointer(
raw,
&[
"/modelCard/considerations/useCases",
"/mlModel/modelCard/considerations/useCases",
"/mlModel/considerations/useCases",
],
),
ml.and_then(|m| m.limitations.as_ref()).is_some(),
ml.is_some_and(|m| !m.ethical_considerations.is_empty())
|| has_non_empty_pointer(
raw,
&[
"/modelCard/considerations/ethicalConsiderations",
"/mlModel/modelCard/considerations/ethicalConsiderations",
"/mlModel/considerations/ethicalConsiderations",
],
),
!component.hashes.is_empty(),
ml_has_exploitability_reference(component),
];
if results.iter().all(|&p| p) {
components_fully_documented += 1;
}
total_weighted_score += results
.iter()
.zip(CHECK_DEFS.iter())
.map(|(&passed, (_, _, w))| if passed { *w / weight_sum } else { 0.0 })
.sum::<f32>();
for (i, &passed) in results.iter().enumerate() {
component_details[i].push(format!(
"{}: {}",
component.name,
if passed { "pass" } else { "fail" }
));
if !passed {
failing_components[i] += 1;
}
}
}
let checks: Vec<AiCheck> = CHECK_DEFS
.iter()
.enumerate()
.map(|(i, (id, name, weight))| {
let failures = failing_components[i];
let detail = if component_details[i].is_empty() {
None
} else {
Some(format!(
"{}/{} components passed; {}",
n - failures,
n,
component_details[i].join("; ")
))
};
AiCheck {
id: (*id).to_string(),
name: (*name).to_string(),
passed: failures == 0,
detail,
weight: *weight / weight_sum,
}
})
.collect();
let overall_score = ((total_weighted_score / n as f32) * 100.0).min(100.0);
let mut recommendations: Vec<Recommendation> = checks
.iter()
.zip(failing_components.iter())
.filter(|(c, _)| !c.passed)
.enumerate()
.map(|(i, (chk, &affected_count))| Recommendation {
priority: (i as u8 / 3) + 1,
category: RecommendationCategory::Completeness,
message: format!("[{}] {}", chk.id, chk.name),
impact: chk.weight * 100.0,
affected_count,
})
.collect();
recommendations.sort_by(|a, b| {
a.priority.cmp(&b.priority).then_with(|| {
b.impact
.partial_cmp(&a.impact)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let metrics = AiReadinessMetrics {
ml_component_count: n,
not_applicable: false,
na_reason: None,
checks,
components_fully_documented,
};
make_report(
overall_score,
QualityGrade::from_score(overall_score),
recommendations,
metrics,
)
}
fn apply_score_caps(
&self,
mut score: f32,
lifecycle: &LifecycleMetrics,
deps: &DependencyMetrics,
hashes: &HashQualityMetrics,
crypto: &CryptographyMetrics,
total_components: usize,
) -> f32 {
let is_security_profile =
matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
if is_security_profile && lifecycle.eol_components > 0 {
score = score.min(69.0);
}
if deps.cycle_count > 0
&& matches!(
self.profile,
ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
)
{
score = score.min(89.0);
}
if matches!(self.profile, ScoringProfile::Security)
&& total_components > 0
&& hashes.components_with_any_hash == 0
{
score = score.min(79.0);
}
if matches!(self.profile, ScoringProfile::Security)
&& hashes.components_with_weak_only > 0
&& hashes.components_with_strong_hash == 0
{
score = score.min(89.0);
}
if self.profile == ScoringProfile::Cbom && crypto.has_data() {
if crypto.weak_algorithm_count > 0 {
score = score.min(69.0);
}
if crypto.compromised_keys > 0 {
score = score.min(79.0);
}
if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
score = score.min(79.0);
}
}
score
}
#[allow(clippy::too_many_arguments)]
fn generate_recommendations(
&self,
completeness: &CompletenessMetrics,
identifiers: &IdentifierMetrics,
licenses: &LicenseMetrics,
dependencies: &DependencyMetrics,
hashes: &HashQualityMetrics,
provenance: &ProvenanceMetrics,
lifecycle: &LifecycleMetrics,
compliance: &ComplianceResult,
total_components: usize,
) -> Vec<Recommendation> {
let mut recommendations = Vec::new();
if compliance.error_count > 0 {
recommendations.push(Recommendation {
priority: 1,
category: RecommendationCategory::Compliance,
message: format!(
"Fix {} compliance error(s) to meet {} requirements",
compliance.error_count,
compliance.level.name()
),
impact: 20.0,
affected_count: compliance.error_count,
});
}
if lifecycle.eol_components > 0 {
recommendations.push(Recommendation {
priority: 1,
category: RecommendationCategory::Lifecycle,
message: format!(
"{} component(s) have reached end-of-life — upgrade or replace",
lifecycle.eol_components
),
impact: 15.0,
affected_count: lifecycle.eol_components,
});
}
let missing_versions = total_components
- ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
if missing_versions > 0 {
recommendations.push(Recommendation {
priority: 1,
category: RecommendationCategory::Completeness,
message: "Add version information to all components".to_string(),
impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
affected_count: missing_versions,
});
}
if hashes.components_with_weak_only > 0 {
recommendations.push(Recommendation {
priority: 2,
category: RecommendationCategory::Integrity,
message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
impact: 10.0,
affected_count: hashes.components_with_weak_only,
});
}
if identifiers.missing_all_identifiers > 0 {
recommendations.push(Recommendation {
priority: 2,
category: RecommendationCategory::Identifiers,
message: "Add PURL or CPE identifiers to components".to_string(),
impact: (identifiers.missing_all_identifiers as f32
/ total_components.max(1) as f32)
* 20.0,
affected_count: identifiers.missing_all_identifiers,
});
}
let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
if invalid_ids > 0 {
recommendations.push(Recommendation {
priority: 2,
category: RecommendationCategory::Identifiers,
message: "Fix malformed PURL/CPE identifiers".to_string(),
impact: 10.0,
affected_count: invalid_ids,
});
}
if !provenance.has_tool_creator {
recommendations.push(Recommendation {
priority: 2,
category: RecommendationCategory::Provenance,
message: "Add SBOM creation tool information".to_string(),
impact: 8.0,
affected_count: 0,
});
}
if dependencies.cycle_count > 0 {
recommendations.push(Recommendation {
priority: 3,
category: RecommendationCategory::Dependencies,
message: format!(
"{} dependency cycle(s) detected — review dependency graph",
dependencies.cycle_count
),
impact: 10.0,
affected_count: dependencies.cycle_count,
});
}
if let Some(level) = &dependencies.complexity_level {
match level {
super::metrics::ComplexityLevel::VeryHigh => {
recommendations.push(Recommendation {
priority: 2,
category: RecommendationCategory::Dependencies,
message:
"Dependency structure is very complex — review for unnecessary transitive dependencies"
.to_string(),
impact: 8.0,
affected_count: dependencies.total_dependencies,
});
}
super::metrics::ComplexityLevel::High => {
recommendations.push(Recommendation {
priority: 3,
category: RecommendationCategory::Dependencies,
message:
"Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
.to_string(),
impact: 5.0,
affected_count: dependencies.total_dependencies,
});
}
_ => {}
}
}
let missing_licenses = total_components - licenses.with_declared;
if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
{
recommendations.push(Recommendation {
priority: 3,
category: RecommendationCategory::Licenses,
message: "Add license information to components".to_string(),
impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
affected_count: missing_licenses,
});
}
if licenses.noassertion_count > 0 {
recommendations.push(Recommendation {
priority: 3,
category: RecommendationCategory::Licenses,
message: "Replace NOASSERTION with actual license information".to_string(),
impact: 5.0,
affected_count: licenses.noassertion_count,
});
}
if total_components > 0 {
let missing_vcs = total_components.saturating_sub(
((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
);
if missing_vcs > total_components / 2 {
recommendations.push(Recommendation {
priority: 3,
category: RecommendationCategory::Provenance,
message: "Add VCS (source repository) URLs to components".to_string(),
impact: 5.0,
affected_count: missing_vcs,
});
}
}
if licenses.non_standard_licenses > 0 {
recommendations.push(Recommendation {
priority: 4,
category: RecommendationCategory::Licenses,
message: "Use SPDX license identifiers for better interoperability".to_string(),
impact: 3.0,
affected_count: licenses.non_standard_licenses,
});
}
if lifecycle.outdated_components > 0 {
recommendations.push(Recommendation {
priority: 4,
category: RecommendationCategory::Lifecycle,
message: format!(
"{} component(s) are outdated — newer versions available",
lifecycle.outdated_components
),
impact: 5.0,
affected_count: lifecycle.outdated_components,
});
}
if provenance.completeness_declaration == CompletenessDeclaration::Unknown
&& matches!(
self.profile,
ScoringProfile::Cra | ScoringProfile::Comprehensive
)
{
recommendations.push(Recommendation {
priority: 4,
category: RecommendationCategory::Provenance,
message: "Add compositions section with aggregate completeness declaration"
.to_string(),
impact: 5.0,
affected_count: 0,
});
}
if total_components > 1 && dependencies.total_dependencies == 0 {
recommendations.push(Recommendation {
priority: 4,
category: RecommendationCategory::Dependencies,
message: "Add dependency relationships between components".to_string(),
impact: 10.0,
affected_count: total_components,
});
}
if dependencies.orphan_components > 1
&& (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
{
recommendations.push(Recommendation {
priority: 4,
category: RecommendationCategory::Dependencies,
message: "Review orphan components that have no dependency relationships"
.to_string(),
impact: 5.0,
affected_count: dependencies.orphan_components,
});
}
let missing_suppliers = total_components
- ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
if missing_suppliers > 0
&& (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
{
recommendations.push(Recommendation {
priority: 5,
category: RecommendationCategory::Completeness,
message: "Add supplier information to components".to_string(),
impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
affected_count: missing_suppliers,
});
}
let missing_hashes = total_components
- ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
if missing_hashes > 0
&& matches!(
self.profile,
ScoringProfile::Security | ScoringProfile::Comprehensive
)
{
recommendations.push(Recommendation {
priority: 5,
category: RecommendationCategory::Integrity,
message: "Add cryptographic hashes for integrity verification".to_string(),
impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
affected_count: missing_hashes,
});
}
if !provenance.has_signature
&& matches!(
self.profile,
ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
)
{
recommendations.push(Recommendation {
priority: 5,
category: RecommendationCategory::Integrity,
message: "Consider adding a digital signature to the SBOM".to_string(),
impact: 3.0,
affected_count: 0,
});
}
recommendations.sort_by(|a, b| {
a.priority.cmp(&b.priority).then_with(|| {
b.impact
.partial_cmp(&a.impact)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
recommendations
}
}
impl Default for QualityScorer {
fn default() -> Self {
Self::new(ScoringProfile::Standard)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Component, ComponentType, DocumentMetadata, MlModelInfo};
use serde_json::json;
#[test]
fn test_grade_from_score() {
assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
}
#[test]
fn test_scoring_profile_compliance_level() {
assert_eq!(
ScoringProfile::Minimal.compliance_level(),
ComplianceLevel::Minimum
);
assert_eq!(
ScoringProfile::Security.compliance_level(),
ComplianceLevel::NtiaMinimum
);
assert_eq!(
ScoringProfile::Comprehensive.compliance_level(),
ComplianceLevel::Comprehensive
);
assert_eq!(
ScoringProfile::AiReadiness.compliance_level(),
ComplianceLevel::Comprehensive
);
}
#[test]
fn test_scoring_weights_sum_to_one() {
let profiles = [
ScoringProfile::Minimal,
ScoringProfile::Standard,
ScoringProfile::Security,
ScoringProfile::LicenseCompliance,
ScoringProfile::Cra,
ScoringProfile::Comprehensive,
ScoringProfile::Cbom,
ScoringProfile::AiReadiness,
];
for profile in &profiles {
let w = profile.weights();
let sum: f32 = w.as_array().iter().sum();
assert!(
(sum - 1.0).abs() < 0.01,
"{profile:?} weights sum to {sum}, expected 1.0"
);
}
}
#[test]
fn test_renormalize_all_available() {
let w = ScoringProfile::Standard.weights();
let available = [true; 8];
let norm = w.renormalize(&available);
let sum: f32 = norm.iter().sum();
assert!((sum - 1.0).abs() < 0.001);
}
#[test]
fn test_renormalize_lifecycle_unavailable() {
let w = ScoringProfile::Standard.weights();
let mut available = [true; 8];
available[7] = false; let norm = w.renormalize(&available);
let sum: f32 = norm.iter().sum();
assert!((sum - 1.0).abs() < 0.001);
assert_eq!(norm[7], 0.0);
}
#[test]
fn test_scoring_engine_version() {
assert_eq!(SCORING_ENGINE_VERSION, "2.1");
}
#[test]
fn cbom_hard_cap_weak_algorithms() {
use crate::model::{
AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
CryptoPrimitive, CryptoProperties, NormalizedSbom,
};
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
comp.component_type = ComponentType::Cryptographic;
comp.crypto_properties = Some(
CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
AlgorithmProperties::new(CryptoPrimitive::Hash)
.with_algorithm_family("MD5".to_string())
.with_nist_quantum_security_level(0),
),
);
sbom.components
.insert(CanonicalId::from_name_version("md5", None), comp);
let scorer = QualityScorer::new(ScoringProfile::Cbom);
let report = scorer.score(&sbom);
assert!(
report.overall_score <= 69.0,
"weak algo should cap at D, got {}",
report.overall_score
);
}
fn ml_component(bom_ref: &str, name: &str, ml: MlModelInfo, raw: Value) -> Component {
let mut component =
Component::new(name.to_string(), bom_ref.to_string()).with_version("1.0.0".to_string());
component.component_type = ComponentType::MachineLearningModel;
component.ml_model = Some(ml);
component.extensions.raw = Some(raw);
component
}
#[test]
fn test_ai_readiness_not_applicable_without_ml_components() {
let sbom = NormalizedSbom::new(DocumentMetadata::default());
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
assert!(metrics.is_not_applicable());
assert_eq!(metrics.ml_component_count, 0);
assert!(metrics.checks.is_empty());
}
#[test]
fn test_ai_readiness_reads_nested_model_card_extensions() {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let ml = MlModelInfo {
architecture_family: Some("transformer".to_string()),
training_datasets: vec![crate::model::DatasetRef {
reference: None,
name: Some("wikipedia-2.5B".to_string()),
purl: None,
}],
energy_kwh_training: Some(1500.0),
model_card_url: Some("https://example.test/model-card".to_string()),
limitations: Some("Only validated for English text".to_string()),
..MlModelInfo::default()
};
let raw = json!({
"mlModel": {
"modelCard": {
"quantitativeAnalysis": {
"performanceMetrics": [{ "type": "accuracy", "value": 0.97 }]
},
"considerations": {
"fairnessConsiderations": ["Assessed on demographic parity"],
"useCases": ["Document classification"],
"ethicalConsiderations": ["Human review required for sensitive domains"]
}
}
}
});
let mut component = ml_component("ml-1", "bert-base", ml, raw);
component.hashes.push(crate::model::Hash::new(
crate::model::HashAlgorithm::Sha256,
"a".repeat(64),
));
component
.vulnerabilities
.push(crate::model::VulnerabilityRef::new(
"CVE-2024-0001".to_string(),
crate::model::VulnerabilitySource::Cve,
));
sbom.add_component(component);
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
assert!(!metrics.is_not_applicable());
for check in &metrics.checks {
assert!(check.passed, "expected {} to pass", check.id);
}
assert_eq!(metrics.checks.len(), 11, "AI-001..AI-011 are all reported");
let weight_total: f32 = metrics.checks.iter().map(|c| c.weight).sum();
assert!(
(weight_total - 1.0).abs() < 0.001,
"renormalized weights must sum to 1.0, got {weight_total}"
);
assert_eq!(metrics.components_fully_documented, 1);
assert!((report.overall_score - 100.0).abs() < 0.01);
assert_eq!(report.grade, QualityGrade::A);
}
#[test]
fn test_ai_readiness_fails_check_when_any_model_is_missing_it() {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let complete_ml = MlModelInfo {
architecture_family: Some("transformer".to_string()),
training_datasets: vec![crate::model::DatasetRef {
reference: None,
name: Some("dataset".to_string()),
purl: None,
}],
energy_kwh_training: Some(10.0),
model_card_url: Some("https://example.test/model-card".to_string()),
limitations: Some("Only validated for English text".to_string()),
..MlModelInfo::default()
};
let complete_raw = json!({
"mlModel": { "modelCard": {
"quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.98 }] },
"considerations": {
"fairnessConsiderations": ["Reviewed"],
"useCases": ["Classification"],
"ethicalConsiderations": ["Human review required"]
}
}}
});
sbom.add_component(ml_component(
"ml-1",
"complete-model",
complete_ml.clone(),
complete_raw,
));
let incomplete_raw = json!({
"mlModel": { "modelCard": {
"quantitativeAnalysis": { "performanceMetrics": [{ "type": "accuracy", "value": 0.94 }] },
"considerations": {
"useCases": ["Classification"],
"ethicalConsiderations": ["Human review required"]
}
}}
});
sbom.add_component(ml_component(
"ml-2",
"incomplete-model",
complete_ml,
incomplete_raw,
));
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
let fairness = metrics
.checks
.iter()
.find(|c| c.id == "AI-005")
.expect("AI-005 should be present");
assert!(
!fairness.passed,
"AI-005 should fail when any model is missing fairness data"
);
assert!(
fairness
.detail
.as_deref()
.unwrap_or_default()
.contains("1/2 components passed")
);
let rec = report
.recommendations
.iter()
.find(|r| r.message.contains("AI-005"))
.expect("missing fairness recommendation");
assert_eq!(rec.affected_count, 1);
}
#[test]
fn test_ai_010_weight_hash_integrity_check() {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let bare = ml_component("ml-1", "no-hash", MlModelInfo::default(), json!({}));
sbom.add_component(bare);
let mut hashed = ml_component("ml-2", "with-hash", MlModelInfo::default(), json!({}));
hashed.hashes.push(crate::model::Hash::new(
crate::model::HashAlgorithm::Sha256,
"b".repeat(64),
));
sbom.add_component(hashed);
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
let ai010 = metrics
.checks
.iter()
.find(|c| c.id == "AI-010")
.expect("AI-010 should be present");
assert!(
!ai010.passed,
"AI-010 should fail when any model is missing weight hashes"
);
assert!(
ai010
.detail
.as_deref()
.unwrap_or_default()
.contains("1/2 components passed"),
"AI-010 detail should report 1/2 models passing"
);
}
#[test]
fn test_ai_011_exploitability_reference_check() {
use crate::model::{
ExternalRefType, ExternalReference, VulnerabilityRef, VulnerabilitySource,
};
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let mut with_vuln = ml_component("ml-1", "with-vuln", MlModelInfo::default(), json!({}));
with_vuln.vulnerabilities.push(VulnerabilityRef::new(
"CVE-2024-1234".to_string(),
VulnerabilitySource::Cve,
));
sbom.add_component(with_vuln);
let mut with_advisory =
ml_component("ml-2", "with-advisory", MlModelInfo::default(), json!({}));
with_advisory.external_refs.push(ExternalReference {
ref_type: ExternalRefType::Advisories,
url: "https://example.test/advisory".to_string(),
comment: None,
hashes: Vec::new(),
});
sbom.add_component(with_advisory);
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
let ai011 = metrics
.checks
.iter()
.find(|c| c.id == "AI-011")
.expect("AI-011 should be present");
assert!(
ai011.passed,
"AI-011 should pass when every model carries a vuln or advisory reference"
);
}
#[test]
fn test_ai_011_fails_without_exploitability_reference() {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
sbom.add_component(ml_component(
"ml-1",
"no-refs",
MlModelInfo::default(),
json!({}),
));
let report = QualityScorer::new(ScoringProfile::AiReadiness).score(&sbom);
let metrics = report
.ai_readiness_metrics
.expect("AI readiness metrics should be present");
let ai011 = metrics
.checks
.iter()
.find(|c| c.id == "AI-011")
.expect("AI-011 should be present");
assert!(
!ai011.passed,
"AI-011 should fail when a model has no exploitability/advisory reference"
);
}
}