1use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::model::{
8 CompletenessDeclaration, CreatorType, EolStatus, ExternalRefType, HashAlgorithm,
9 NormalizedSbom, StalenessLevel,
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompletenessMetrics {
16 pub components_with_version: f32,
18 pub components_with_purl: f32,
20 pub components_with_cpe: f32,
22 pub components_with_supplier: f32,
24 pub components_with_hashes: f32,
26 pub components_with_licenses: f32,
28 pub components_with_description: f32,
30 pub has_creator_info: bool,
32 pub has_timestamp: bool,
34 pub has_serial_number: bool,
36 pub total_components: usize,
38}
39
40impl CompletenessMetrics {
41 #[must_use]
43 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
44 let total = sbom.components.len();
45 if total == 0 {
46 return Self::empty();
47 }
48
49 let mut with_version = 0;
50 let mut with_purl = 0;
51 let mut with_cpe = 0;
52 let mut with_supplier = 0;
53 let mut with_hashes = 0;
54 let mut with_licenses = 0;
55 let mut with_description = 0;
56
57 for comp in sbom.components.values() {
58 if comp.version.is_some() {
59 with_version += 1;
60 }
61 if comp.identifiers.purl.is_some() {
62 with_purl += 1;
63 }
64 if !comp.identifiers.cpe.is_empty() {
65 with_cpe += 1;
66 }
67 if comp.supplier.is_some() {
68 with_supplier += 1;
69 }
70 if !comp.hashes.is_empty() {
71 with_hashes += 1;
72 }
73 if !comp.licenses.declared.is_empty() || comp.licenses.concluded.is_some() {
74 with_licenses += 1;
75 }
76 if comp.description.is_some() {
77 with_description += 1;
78 }
79 }
80
81 let pct = |count: usize| (count as f32 / total as f32) * 100.0;
82
83 Self {
84 components_with_version: pct(with_version),
85 components_with_purl: pct(with_purl),
86 components_with_cpe: pct(with_cpe),
87 components_with_supplier: pct(with_supplier),
88 components_with_hashes: pct(with_hashes),
89 components_with_licenses: pct(with_licenses),
90 components_with_description: pct(with_description),
91 has_creator_info: !sbom.document.creators.is_empty(),
92 has_timestamp: true, has_serial_number: sbom.document.serial_number.is_some(),
94 total_components: total,
95 }
96 }
97
98 #[must_use]
100 pub const fn empty() -> Self {
101 Self {
102 components_with_version: 0.0,
103 components_with_purl: 0.0,
104 components_with_cpe: 0.0,
105 components_with_supplier: 0.0,
106 components_with_hashes: 0.0,
107 components_with_licenses: 0.0,
108 components_with_description: 0.0,
109 has_creator_info: false,
110 has_timestamp: false,
111 has_serial_number: false,
112 total_components: 0,
113 }
114 }
115
116 #[must_use]
118 pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
119 let mut score = 0.0;
120 let mut total_weight = 0.0;
121
122 score += self.components_with_version * weights.version;
124 total_weight += weights.version * 100.0;
125
126 score += self.components_with_purl * weights.purl;
127 total_weight += weights.purl * 100.0;
128
129 score += self.components_with_cpe * weights.cpe;
130 total_weight += weights.cpe * 100.0;
131
132 score += self.components_with_supplier * weights.supplier;
133 total_weight += weights.supplier * 100.0;
134
135 score += self.components_with_hashes * weights.hashes;
136 total_weight += weights.hashes * 100.0;
137
138 score += self.components_with_licenses * weights.licenses;
139 total_weight += weights.licenses * 100.0;
140
141 if self.has_creator_info {
143 score += 100.0 * weights.creator_info;
144 }
145 total_weight += weights.creator_info * 100.0;
146
147 if self.has_serial_number {
148 score += 100.0 * weights.serial_number;
149 }
150 total_weight += weights.serial_number * 100.0;
151
152 if total_weight > 0.0 {
153 (score / total_weight) * 100.0
154 } else {
155 0.0
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct CompletenessWeights {
163 pub version: f32,
164 pub purl: f32,
165 pub cpe: f32,
166 pub supplier: f32,
167 pub hashes: f32,
168 pub licenses: f32,
169 pub creator_info: f32,
170 pub serial_number: f32,
171}
172
173impl Default for CompletenessWeights {
174 fn default() -> Self {
175 Self {
176 version: 1.0,
177 purl: 1.5, cpe: 0.5, supplier: 1.0,
180 hashes: 1.0,
181 licenses: 1.2, creator_info: 0.3,
183 serial_number: 0.2,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HashQualityMetrics {
195 pub components_with_any_hash: usize,
197 pub components_with_strong_hash: usize,
199 pub components_with_weak_only: usize,
201 pub algorithm_distribution: BTreeMap<String, usize>,
203 pub total_hashes: usize,
205}
206
207impl HashQualityMetrics {
208 #[must_use]
210 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
211 let mut with_any = 0;
212 let mut with_strong = 0;
213 let mut with_weak_only = 0;
214 let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
215 let mut total_hashes = 0;
216
217 for comp in sbom.components.values() {
218 if comp.hashes.is_empty() {
219 continue;
220 }
221 with_any += 1;
222 total_hashes += comp.hashes.len();
223
224 let mut has_strong = false;
225 let mut has_weak = false;
226
227 for hash in &comp.hashes {
228 let label = hash_algorithm_label(&hash.algorithm);
229 *distribution.entry(label).or_insert(0) += 1;
230
231 if is_strong_hash(&hash.algorithm) {
232 has_strong = true;
233 } else {
234 has_weak = true;
235 }
236 }
237
238 if has_strong {
239 with_strong += 1;
240 } else if has_weak {
241 with_weak_only += 1;
242 }
243 }
244
245 Self {
246 components_with_any_hash: with_any,
247 components_with_strong_hash: with_strong,
248 components_with_weak_only: with_weak_only,
249 algorithm_distribution: distribution,
250 total_hashes,
251 }
252 }
253
254 #[must_use]
259 pub fn quality_score(&self, total_components: usize) -> f32 {
260 if total_components == 0 {
261 return 0.0;
262 }
263
264 let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
265 let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
266 let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
267
268 let base = any_coverage * 60.0;
269 let strong_bonus = strong_coverage * 40.0;
270 let weak_penalty = weak_only_ratio * 10.0;
271
272 (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
273 }
274}
275
276fn is_strong_hash(algo: &HashAlgorithm) -> bool {
278 matches!(
279 algo,
280 HashAlgorithm::Sha256
281 | HashAlgorithm::Sha384
282 | HashAlgorithm::Sha512
283 | HashAlgorithm::Sha3_256
284 | HashAlgorithm::Sha3_384
285 | HashAlgorithm::Sha3_512
286 | HashAlgorithm::Blake2b256
287 | HashAlgorithm::Blake2b384
288 | HashAlgorithm::Blake2b512
289 | HashAlgorithm::Blake3
290 )
291}
292
293fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
295 match algo {
296 HashAlgorithm::Md5 => "MD5".to_string(),
297 HashAlgorithm::Sha1 => "SHA-1".to_string(),
298 HashAlgorithm::Sha256 => "SHA-256".to_string(),
299 HashAlgorithm::Sha384 => "SHA-384".to_string(),
300 HashAlgorithm::Sha512 => "SHA-512".to_string(),
301 HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
302 HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
303 HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
304 HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
305 HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
306 HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
307 HashAlgorithm::Blake3 => "BLAKE3".to_string(),
308 HashAlgorithm::Other(s) => s.clone(),
309 }
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct IdentifierMetrics {
319 pub valid_purls: usize,
321 pub invalid_purls: usize,
323 pub valid_cpes: usize,
325 pub invalid_cpes: usize,
327 pub with_swid: usize,
329 pub ecosystems: Vec<String>,
331 pub missing_all_identifiers: usize,
333}
334
335impl IdentifierMetrics {
336 #[must_use]
338 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
339 let mut valid_purls = 0;
340 let mut invalid_purls = 0;
341 let mut valid_cpes = 0;
342 let mut invalid_cpes = 0;
343 let mut with_swid = 0;
344 let mut missing_all = 0;
345 let mut ecosystems = std::collections::HashSet::new();
346
347 for comp in sbom.components.values() {
348 let has_purl = comp.identifiers.purl.is_some();
349 let has_cpe = !comp.identifiers.cpe.is_empty();
350 let has_swid = comp.identifiers.swid.is_some();
351
352 if let Some(ref purl) = comp.identifiers.purl {
353 if is_valid_purl(purl) {
354 valid_purls += 1;
355 if let Some(eco) = extract_ecosystem_from_purl(purl) {
357 ecosystems.insert(eco);
358 }
359 } else {
360 invalid_purls += 1;
361 }
362 }
363
364 for cpe in &comp.identifiers.cpe {
365 if is_valid_cpe(cpe) {
366 valid_cpes += 1;
367 } else {
368 invalid_cpes += 1;
369 }
370 }
371
372 if has_swid {
373 with_swid += 1;
374 }
375
376 if !has_purl && !has_cpe && !has_swid {
377 missing_all += 1;
378 }
379 }
380
381 let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
382 ecosystem_list.sort();
383
384 Self {
385 valid_purls,
386 invalid_purls,
387 valid_cpes,
388 invalid_cpes,
389 with_swid,
390 ecosystems: ecosystem_list,
391 missing_all_identifiers: missing_all,
392 }
393 }
394
395 #[must_use]
397 pub fn quality_score(&self, total_components: usize) -> f32 {
398 if total_components == 0 {
399 return 0.0;
400 }
401
402 let with_valid_id = self.valid_purls + self.valid_cpes + self.with_swid;
403 let coverage =
404 (with_valid_id.min(total_components) as f32 / total_components as f32) * 100.0;
405
406 let invalid_count = self.invalid_purls + self.invalid_cpes;
408 let penalty = (invalid_count as f32 / total_components as f32) * 20.0;
409
410 (coverage - penalty).clamp(0.0, 100.0)
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct LicenseMetrics {
417 pub with_declared: usize,
419 pub with_concluded: usize,
421 pub valid_spdx_expressions: usize,
423 pub non_standard_licenses: usize,
425 pub noassertion_count: usize,
427 pub deprecated_licenses: usize,
429 pub restrictive_licenses: usize,
431 pub copyleft_license_ids: Vec<String>,
433 pub unique_licenses: Vec<String>,
435}
436
437impl LicenseMetrics {
438 #[must_use]
440 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
441 let mut with_declared = 0;
442 let mut with_concluded = 0;
443 let mut valid_spdx = 0;
444 let mut non_standard = 0;
445 let mut noassertion = 0;
446 let mut deprecated = 0;
447 let mut restrictive = 0;
448 let mut licenses = HashSet::new();
449 let mut copyleft_ids = HashSet::new();
450
451 for comp in sbom.components.values() {
452 if !comp.licenses.declared.is_empty() {
453 with_declared += 1;
454 for lic in &comp.licenses.declared {
455 let expr = &lic.expression;
456 licenses.insert(expr.clone());
457
458 if expr == "NOASSERTION" {
459 noassertion += 1;
460 } else if is_valid_spdx_license(expr) {
461 valid_spdx += 1;
462 } else {
463 non_standard += 1;
464 }
465
466 if is_deprecated_spdx_license(expr) {
467 deprecated += 1;
468 }
469 if is_restrictive_license(expr) {
470 restrictive += 1;
471 copyleft_ids.insert(expr.clone());
472 }
473 }
474 }
475
476 if comp.licenses.concluded.is_some() {
477 with_concluded += 1;
478 }
479 }
480
481 let mut license_list: Vec<String> = licenses.into_iter().collect();
482 license_list.sort();
483
484 let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
485 copyleft_list.sort();
486
487 Self {
488 with_declared,
489 with_concluded,
490 valid_spdx_expressions: valid_spdx,
491 non_standard_licenses: non_standard,
492 noassertion_count: noassertion,
493 deprecated_licenses: deprecated,
494 restrictive_licenses: restrictive,
495 copyleft_license_ids: copyleft_list,
496 unique_licenses: license_list,
497 }
498 }
499
500 #[must_use]
502 pub fn quality_score(&self, total_components: usize) -> f32 {
503 if total_components == 0 {
504 return 0.0;
505 }
506
507 let coverage = (self.with_declared as f32 / total_components as f32) * 60.0;
508
509 let spdx_ratio = if self.with_declared > 0 {
511 self.valid_spdx_expressions as f32 / self.with_declared as f32
512 } else {
513 0.0
514 };
515 let spdx_bonus = spdx_ratio * 30.0;
516
517 let noassertion_penalty =
519 (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
520
521 let deprecated_penalty =
523 (self.deprecated_licenses as f32 * 2.0).min(10.0);
524
525 (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
526 }
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct VulnerabilityMetrics {
532 pub components_with_vulns: usize,
534 pub total_vulnerabilities: usize,
536 pub with_cvss: usize,
538 pub with_cwe: usize,
540 pub with_remediation: usize,
542 pub with_vex_status: usize,
544}
545
546impl VulnerabilityMetrics {
547 #[must_use]
549 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
550 let mut components_with_vulns = 0;
551 let mut total_vulns = 0;
552 let mut with_cvss = 0;
553 let mut with_cwe = 0;
554 let mut with_remediation = 0;
555 let mut with_vex = 0;
556
557 for comp in sbom.components.values() {
558 if !comp.vulnerabilities.is_empty() {
559 components_with_vulns += 1;
560 }
561
562 for vuln in &comp.vulnerabilities {
563 total_vulns += 1;
564
565 if !vuln.cvss.is_empty() {
566 with_cvss += 1;
567 }
568 if !vuln.cwes.is_empty() {
569 with_cwe += 1;
570 }
571 if vuln.remediation.is_some() {
572 with_remediation += 1;
573 }
574 }
575
576 if comp.vex_status.is_some()
577 || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
578 {
579 with_vex += 1;
580 }
581 }
582
583 Self {
584 components_with_vulns,
585 total_vulnerabilities: total_vulns,
586 with_cvss,
587 with_cwe,
588 with_remediation,
589 with_vex_status: with_vex,
590 }
591 }
592
593 #[must_use]
596 pub fn documentation_score(&self) -> f32 {
597 if self.total_vulnerabilities == 0 {
598 return 100.0; }
600
601 let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
602 let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
603 let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
604
605 remediation_ratio.mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0)).min(100.0)
606 }
607}
608
609const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 50_000;
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct DependencyMetrics {
619 pub total_dependencies: usize,
621 pub components_with_deps: usize,
623 pub max_depth: Option<usize>,
625 pub avg_depth: Option<f32>,
627 pub orphan_components: usize,
629 pub root_components: usize,
631 pub cycle_count: usize,
633 pub island_count: usize,
635 pub graph_analysis_skipped: bool,
637}
638
639impl DependencyMetrics {
640 #[must_use]
642 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
643 use crate::model::CanonicalId;
644
645 let total_deps = sbom.edges.len();
646
647 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
649 let mut has_outgoing: HashSet<&str> = HashSet::new();
650 let mut has_incoming: HashSet<&str> = HashSet::new();
651
652 for edge in &sbom.edges {
653 children
654 .entry(edge.from.value())
655 .or_default()
656 .push(edge.to.value());
657 has_outgoing.insert(edge.from.value());
658 has_incoming.insert(edge.to.value());
659 }
660
661 let all_ids: Vec<&str> = sbom
662 .components
663 .keys()
664 .map(CanonicalId::value)
665 .collect();
666
667 let orphans = all_ids
668 .iter()
669 .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
670 .count();
671
672 let roots: Vec<&str> = has_outgoing
673 .iter()
674 .filter(|c| !has_incoming.contains(*c))
675 .copied()
676 .collect();
677 let root_count = roots.len();
678
679 if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
681 return Self {
682 total_dependencies: total_deps,
683 components_with_deps: has_outgoing.len(),
684 max_depth: None,
685 avg_depth: None,
686 orphan_components: orphans,
687 root_components: root_count,
688 cycle_count: 0,
689 island_count: 0,
690 graph_analysis_skipped: true,
691 };
692 }
693
694 let (max_depth, avg_depth) = compute_depth(&roots, &children);
696
697 let cycle_count = detect_cycles(&all_ids, &children);
699
700 let island_count = count_islands(&all_ids, &sbom.edges);
702
703 Self {
704 total_dependencies: total_deps,
705 components_with_deps: has_outgoing.len(),
706 max_depth,
707 avg_depth,
708 orphan_components: orphans,
709 root_components: root_count,
710 cycle_count,
711 island_count,
712 graph_analysis_skipped: false,
713 }
714 }
715
716 #[must_use]
718 pub fn quality_score(&self, total_components: usize) -> f32 {
719 if total_components == 0 {
720 return 0.0;
721 }
722
723 let coverage = if total_components > 1 {
725 (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
726 } else {
727 100.0 };
729
730 let orphan_ratio = self.orphan_components as f32 / total_components as f32;
732 let orphan_penalty = orphan_ratio * 10.0;
733
734 let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
736
737 let island_penalty = if total_components > 5 && self.island_count > 3 {
739 ((self.island_count - 3) as f32 * 3.0).min(15.0)
740 } else {
741 0.0
742 };
743
744 (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
745 }
746}
747
748fn compute_depth(roots: &[&str], children: &HashMap<&str, Vec<&str>>) -> (Option<usize>, Option<f32>) {
750 use std::collections::VecDeque;
751
752 if roots.is_empty() {
753 return (None, None);
754 }
755
756 let mut visited: HashSet<&str> = HashSet::new();
757 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
758 let mut max_d: usize = 0;
759 let mut total_depth: usize = 0;
760 let mut count: usize = 0;
761
762 for &root in roots {
763 if visited.insert(root) {
764 queue.push_back((root, 0));
765 }
766 }
767
768 while let Some((node, depth)) = queue.pop_front() {
769 max_d = max_d.max(depth);
770 total_depth += depth;
771 count += 1;
772
773 if let Some(kids) = children.get(node) {
774 for &kid in kids {
775 if visited.insert(kid) {
776 queue.push_back((kid, depth + 1));
777 }
778 }
779 }
780 }
781
782 let avg = if count > 0 {
783 Some(total_depth as f32 / count as f32)
784 } else {
785 None
786 };
787
788 (Some(max_d), avg)
789}
790
791fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
793 const WHITE: u8 = 0;
794 const GRAY: u8 = 1;
795 const BLACK: u8 = 2;
796
797 let mut color: HashMap<&str, u8> = HashMap::with_capacity(all_nodes.len());
798 for &node in all_nodes {
799 color.insert(node, WHITE);
800 }
801
802 let mut cycles = 0;
803
804 fn dfs<'a>(
805 node: &'a str,
806 children: &HashMap<&str, Vec<&'a str>>,
807 color: &mut HashMap<&'a str, u8>,
808 cycles: &mut usize,
809 ) {
810 color.insert(node, GRAY);
811
812 if let Some(kids) = children.get(node) {
813 for &kid in kids {
814 match color.get(kid).copied().unwrap_or(WHITE) {
815 GRAY => *cycles += 1, WHITE => dfs(kid, children, color, cycles),
817 _ => {}
818 }
819 }
820 }
821
822 color.insert(node, BLACK);
823 }
824
825 for &node in all_nodes {
826 if color.get(node).copied().unwrap_or(WHITE) == WHITE {
827 dfs(node, children, &mut color, &mut cycles);
828 }
829 }
830
831 cycles
832}
833
834fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
836 if all_nodes.is_empty() {
837 return 0;
838 }
839
840 let node_idx: HashMap<&str, usize> = all_nodes
842 .iter()
843 .enumerate()
844 .map(|(i, &n)| (n, i))
845 .collect();
846
847 let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
848 let mut rank: Vec<u8> = vec![0; all_nodes.len()];
849
850 fn find(parent: &mut Vec<usize>, x: usize) -> usize {
851 if parent[x] != x {
852 parent[x] = find(parent, parent[x]); }
854 parent[x]
855 }
856
857 fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
858 let ra = find(parent, a);
859 let rb = find(parent, b);
860 if ra != rb {
861 if rank[ra] < rank[rb] {
862 parent[ra] = rb;
863 } else if rank[ra] > rank[rb] {
864 parent[rb] = ra;
865 } else {
866 parent[rb] = ra;
867 rank[ra] += 1;
868 }
869 }
870 }
871
872 for edge in edges {
873 if let (Some(&a), Some(&b)) = (
874 node_idx.get(edge.from.value()),
875 node_idx.get(edge.to.value()),
876 ) {
877 union(&mut parent, &mut rank, a, b);
878 }
879 }
880
881 let mut roots = HashSet::new();
883 for i in 0..all_nodes.len() {
884 roots.insert(find(&mut parent, i));
885 }
886
887 roots.len()
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
896pub struct ProvenanceMetrics {
897 pub has_tool_creator: bool,
899 pub has_tool_version: bool,
901 pub has_org_creator: bool,
903 pub has_contact_email: bool,
905 pub has_serial_number: bool,
907 pub has_document_name: bool,
909 pub timestamp_age_days: u32,
911 pub is_fresh: bool,
913 pub has_primary_component: bool,
915 pub lifecycle_phase: Option<String>,
917 pub completeness_declaration: CompletenessDeclaration,
919 pub has_signature: bool,
921}
922
923const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
925
926impl ProvenanceMetrics {
927 #[must_use]
929 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
930 let doc = &sbom.document;
931
932 let has_tool_creator = doc
933 .creators
934 .iter()
935 .any(|c| c.creator_type == CreatorType::Tool);
936 let has_tool_version = doc.creators.iter().any(|c| {
937 c.creator_type == CreatorType::Tool
938 && (c.name.contains(' ') || c.name.contains('/') || c.name.contains('@'))
939 });
940 let has_org_creator = doc
941 .creators
942 .iter()
943 .any(|c| c.creator_type == CreatorType::Organization);
944 let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
945
946 let age_days = (chrono::Utc::now() - doc.created)
947 .num_days()
948 .max(0) as u32;
949
950 Self {
951 has_tool_creator,
952 has_tool_version,
953 has_org_creator,
954 has_contact_email,
955 has_serial_number: doc.serial_number.is_some(),
956 has_document_name: doc.name.is_some(),
957 timestamp_age_days: age_days,
958 is_fresh: age_days < FRESHNESS_THRESHOLD_DAYS,
959 has_primary_component: sbom.primary_component_id.is_some(),
960 lifecycle_phase: doc.lifecycle_phase.clone(),
961 completeness_declaration: doc.completeness_declaration.clone(),
962 has_signature: doc.signature.is_some(),
963 }
964 }
965
966 #[must_use]
973 pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
974 let mut score = 0.0;
975 let mut total_weight = 0.0;
976
977 let completeness_declared =
978 self.completeness_declaration != CompletenessDeclaration::Unknown;
979
980 let checks: &[(bool, f32)] = &[
981 (self.has_tool_creator, 15.0),
982 (self.has_tool_version, 5.0),
983 (self.has_org_creator, 12.0),
984 (self.has_contact_email, 8.0),
985 (self.has_serial_number, 8.0),
986 (self.has_document_name, 5.0),
987 (self.is_fresh, 12.0),
988 (self.has_primary_component, 12.0),
989 (completeness_declared, 8.0),
990 (self.has_signature, 5.0),
991 ];
992
993 for &(present, weight) in checks {
994 if present {
995 score += weight;
996 }
997 total_weight += weight;
998 }
999
1000 if is_cyclonedx {
1002 let weight = 10.0;
1003 if self.lifecycle_phase.is_some() {
1004 score += weight;
1005 }
1006 total_weight += weight;
1007 }
1008
1009 if total_weight > 0.0 {
1010 (score / total_weight) * 100.0
1011 } else {
1012 0.0
1013 }
1014 }
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1023pub struct AuditabilityMetrics {
1024 pub components_with_vcs: usize,
1026 pub components_with_website: usize,
1028 pub components_with_advisories: usize,
1030 pub components_with_any_external_ref: usize,
1032 pub has_security_contact: bool,
1034 pub has_vuln_disclosure_url: bool,
1036}
1037
1038impl AuditabilityMetrics {
1039 #[must_use]
1041 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1042 let mut with_vcs = 0;
1043 let mut with_website = 0;
1044 let mut with_advisories = 0;
1045 let mut with_any = 0;
1046
1047 for comp in sbom.components.values() {
1048 if comp.external_refs.is_empty() {
1049 continue;
1050 }
1051 with_any += 1;
1052
1053 let has_vcs = comp
1054 .external_refs
1055 .iter()
1056 .any(|r| r.ref_type == ExternalRefType::Vcs);
1057 let has_website = comp
1058 .external_refs
1059 .iter()
1060 .any(|r| r.ref_type == ExternalRefType::Website);
1061 let has_advisories = comp
1062 .external_refs
1063 .iter()
1064 .any(|r| r.ref_type == ExternalRefType::Advisories);
1065
1066 if has_vcs {
1067 with_vcs += 1;
1068 }
1069 if has_website {
1070 with_website += 1;
1071 }
1072 if has_advisories {
1073 with_advisories += 1;
1074 }
1075 }
1076
1077 Self {
1078 components_with_vcs: with_vcs,
1079 components_with_website: with_website,
1080 components_with_advisories: with_advisories,
1081 components_with_any_external_ref: with_any,
1082 has_security_contact: sbom.document.security_contact.is_some(),
1083 has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1084 }
1085 }
1086
1087 #[must_use]
1091 pub fn quality_score(&self, total_components: usize) -> f32 {
1092 if total_components == 0 {
1093 return 0.0;
1094 }
1095
1096 let ref_coverage =
1098 (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1099 let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1100
1101 let security_contact_score = if self.has_security_contact {
1103 20.0
1104 } else {
1105 0.0
1106 };
1107 let disclosure_score = if self.has_vuln_disclosure_url {
1108 20.0
1109 } else {
1110 0.0
1111 };
1112
1113 (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1114 }
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1123pub struct LifecycleMetrics {
1124 pub eol_components: usize,
1126 pub stale_components: usize,
1128 pub deprecated_components: usize,
1130 pub archived_components: usize,
1132 pub outdated_components: usize,
1134 pub enriched_components: usize,
1136 pub enrichment_coverage: f32,
1138}
1139
1140impl LifecycleMetrics {
1141 #[must_use]
1147 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1148 let total = sbom.components.len();
1149 let mut eol = 0;
1150 let mut stale = 0;
1151 let mut deprecated = 0;
1152 let mut archived = 0;
1153 let mut outdated = 0;
1154 let mut enriched = 0;
1155
1156 for comp in sbom.components.values() {
1157 let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1158 if has_lifecycle_data {
1159 enriched += 1;
1160 }
1161
1162 if let Some(ref eol_info) = comp.eol
1163 && eol_info.status == EolStatus::EndOfLife {
1164 eol += 1;
1165 }
1166
1167 if let Some(ref stale_info) = comp.staleness {
1168 match stale_info.level {
1169 StalenessLevel::Stale | StalenessLevel::Abandoned => stale += 1,
1170 StalenessLevel::Deprecated => deprecated += 1,
1171 StalenessLevel::Archived => archived += 1,
1172 _ => {}
1173 }
1174 if stale_info.is_deprecated {
1175 deprecated += 1;
1176 }
1177 if stale_info.is_archived {
1178 archived += 1;
1179 }
1180 if stale_info.latest_version.is_some() {
1181 outdated += 1;
1182 }
1183 }
1184 }
1185
1186 let coverage = if total > 0 {
1187 (enriched as f32 / total as f32) * 100.0
1188 } else {
1189 0.0
1190 };
1191
1192 Self {
1193 eol_components: eol,
1194 stale_components: stale,
1195 deprecated_components: deprecated,
1196 archived_components: archived,
1197 outdated_components: outdated,
1198 enriched_components: enriched,
1199 enrichment_coverage: coverage,
1200 }
1201 }
1202
1203 #[must_use]
1205 pub fn has_data(&self) -> bool {
1206 self.enriched_components > 0
1207 }
1208
1209 #[must_use]
1214 pub fn quality_score(&self) -> Option<f32> {
1215 if !self.has_data() {
1216 return None;
1217 }
1218
1219 let mut score = 100.0_f32;
1220
1221 score -= (self.eol_components as f32 * 15.0).min(60.0);
1223 score -= (self.stale_components as f32 * 5.0).min(30.0);
1225 score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1227 score -= (self.outdated_components as f32 * 1.0).min(10.0);
1229
1230 Some(score.clamp(0.0, 100.0))
1231 }
1232}
1233
1234fn is_valid_purl(purl: &str) -> bool {
1239 purl.starts_with("pkg:") && purl.contains('/')
1241}
1242
1243fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
1244 if let Some(rest) = purl.strip_prefix("pkg:")
1246 && let Some(slash_idx) = rest.find('/') {
1247 return Some(rest[..slash_idx].to_string());
1248 }
1249 None
1250}
1251
1252fn is_valid_cpe(cpe: &str) -> bool {
1253 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
1255}
1256
1257fn is_valid_spdx_license(expr: &str) -> bool {
1258 const COMMON_SPDX: &[&str] = &[
1260 "MIT",
1261 "Apache-2.0",
1262 "GPL-2.0",
1263 "GPL-3.0",
1264 "BSD-2-Clause",
1265 "BSD-3-Clause",
1266 "ISC",
1267 "MPL-2.0",
1268 "LGPL-2.1",
1269 "LGPL-3.0",
1270 "AGPL-3.0",
1271 "Unlicense",
1272 "CC0-1.0",
1273 "0BSD",
1274 "EPL-2.0",
1275 "CDDL-1.0",
1276 "Artistic-2.0",
1277 "GPL-2.0-only",
1278 "GPL-2.0-or-later",
1279 "GPL-3.0-only",
1280 "GPL-3.0-or-later",
1281 "LGPL-2.1-only",
1282 "LGPL-2.1-or-later",
1283 "LGPL-3.0-only",
1284 "LGPL-3.0-or-later",
1285 ];
1286
1287 let trimmed = expr.trim();
1289 COMMON_SPDX.contains(&trimmed)
1290 || trimmed.contains(" AND ")
1291 || trimmed.contains(" OR ")
1292 || trimmed.contains(" WITH ")
1293}
1294
1295fn is_deprecated_spdx_license(expr: &str) -> bool {
1300 const DEPRECATED: &[&str] = &[
1301 "GPL-2.0",
1302 "GPL-2.0+",
1303 "GPL-3.0",
1304 "GPL-3.0+",
1305 "LGPL-2.0",
1306 "LGPL-2.0+",
1307 "LGPL-2.1",
1308 "LGPL-2.1+",
1309 "LGPL-3.0",
1310 "LGPL-3.0+",
1311 "AGPL-1.0",
1312 "AGPL-3.0",
1313 "GFDL-1.1",
1314 "GFDL-1.2",
1315 "GFDL-1.3",
1316 "BSD-2-Clause-FreeBSD",
1317 "BSD-2-Clause-NetBSD",
1318 "eCos-2.0",
1319 "Nunit",
1320 "StandardML-NJ",
1321 "wxWindows",
1322 ];
1323 let trimmed = expr.trim();
1324 DEPRECATED.contains(&trimmed)
1325}
1326
1327fn is_restrictive_license(expr: &str) -> bool {
1332 let trimmed = expr.trim().to_uppercase();
1333 trimmed.starts_with("GPL")
1334 || trimmed.starts_with("LGPL")
1335 || trimmed.starts_with("AGPL")
1336 || trimmed.starts_with("EUPL")
1337 || trimmed.starts_with("SSPL")
1338 || trimmed.starts_with("OSL")
1339 || trimmed.starts_with("CPAL")
1340 || trimmed.starts_with("CC-BY-SA")
1341 || trimmed.starts_with("CC-BY-NC")
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347
1348 #[test]
1349 fn test_purl_validation() {
1350 assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
1351 assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
1352 assert!(!is_valid_purl("npm:something"));
1353 assert!(!is_valid_purl("invalid"));
1354 }
1355
1356 #[test]
1357 fn test_cpe_validation() {
1358 assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
1359 assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
1360 assert!(!is_valid_cpe("something:else"));
1361 }
1362
1363 #[test]
1364 fn test_spdx_license_validation() {
1365 assert!(is_valid_spdx_license("MIT"));
1366 assert!(is_valid_spdx_license("Apache-2.0"));
1367 assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
1368 assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
1369 }
1370
1371 #[test]
1372 fn test_strong_hash_classification() {
1373 assert!(is_strong_hash(&HashAlgorithm::Sha256));
1374 assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
1375 assert!(is_strong_hash(&HashAlgorithm::Blake3));
1376 assert!(!is_strong_hash(&HashAlgorithm::Md5));
1377 assert!(!is_strong_hash(&HashAlgorithm::Sha1));
1378 assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
1379 }
1380
1381 #[test]
1382 fn test_deprecated_license_detection() {
1383 assert!(is_deprecated_spdx_license("GPL-2.0"));
1384 assert!(is_deprecated_spdx_license("LGPL-2.1"));
1385 assert!(is_deprecated_spdx_license("AGPL-3.0"));
1386 assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
1387 assert!(!is_deprecated_spdx_license("MIT"));
1388 assert!(!is_deprecated_spdx_license("Apache-2.0"));
1389 }
1390
1391 #[test]
1392 fn test_restrictive_license_detection() {
1393 assert!(is_restrictive_license("GPL-3.0-only"));
1394 assert!(is_restrictive_license("LGPL-2.1-or-later"));
1395 assert!(is_restrictive_license("AGPL-3.0-only"));
1396 assert!(is_restrictive_license("EUPL-1.2"));
1397 assert!(is_restrictive_license("CC-BY-SA-4.0"));
1398 assert!(!is_restrictive_license("MIT"));
1399 assert!(!is_restrictive_license("Apache-2.0"));
1400 assert!(!is_restrictive_license("BSD-3-Clause"));
1401 }
1402
1403 #[test]
1404 fn test_hash_quality_score_no_components() {
1405 let metrics = HashQualityMetrics {
1406 components_with_any_hash: 0,
1407 components_with_strong_hash: 0,
1408 components_with_weak_only: 0,
1409 algorithm_distribution: BTreeMap::new(),
1410 total_hashes: 0,
1411 };
1412 assert_eq!(metrics.quality_score(0), 0.0);
1413 }
1414
1415 #[test]
1416 fn test_hash_quality_score_all_strong() {
1417 let metrics = HashQualityMetrics {
1418 components_with_any_hash: 10,
1419 components_with_strong_hash: 10,
1420 components_with_weak_only: 0,
1421 algorithm_distribution: BTreeMap::new(),
1422 total_hashes: 10,
1423 };
1424 assert_eq!(metrics.quality_score(10), 100.0);
1425 }
1426
1427 #[test]
1428 fn test_hash_quality_score_weak_only_penalty() {
1429 let metrics = HashQualityMetrics {
1430 components_with_any_hash: 10,
1431 components_with_strong_hash: 0,
1432 components_with_weak_only: 10,
1433 algorithm_distribution: BTreeMap::new(),
1434 total_hashes: 10,
1435 };
1436 assert_eq!(metrics.quality_score(10), 50.0);
1438 }
1439
1440 #[test]
1441 fn test_lifecycle_no_enrichment_returns_none() {
1442 let metrics = LifecycleMetrics {
1443 eol_components: 0,
1444 stale_components: 0,
1445 deprecated_components: 0,
1446 archived_components: 0,
1447 outdated_components: 0,
1448 enriched_components: 0,
1449 enrichment_coverage: 0.0,
1450 };
1451 assert!(!metrics.has_data());
1452 assert!(metrics.quality_score().is_none());
1453 }
1454
1455 #[test]
1456 fn test_lifecycle_with_eol_penalty() {
1457 let metrics = LifecycleMetrics {
1458 eol_components: 2,
1459 stale_components: 0,
1460 deprecated_components: 0,
1461 archived_components: 0,
1462 outdated_components: 0,
1463 enriched_components: 10,
1464 enrichment_coverage: 100.0,
1465 };
1466 assert_eq!(metrics.quality_score(), Some(70.0));
1468 }
1469
1470 #[test]
1471 fn test_cycle_detection_no_cycles() {
1472 let children: HashMap<&str, Vec<&str>> = HashMap::from([
1473 ("a", vec!["b"]),
1474 ("b", vec!["c"]),
1475 ]);
1476 let all_nodes = vec!["a", "b", "c"];
1477 assert_eq!(detect_cycles(&all_nodes, &children), 0);
1478 }
1479
1480 #[test]
1481 fn test_cycle_detection_with_cycle() {
1482 let children: HashMap<&str, Vec<&str>> = HashMap::from([
1483 ("a", vec!["b"]),
1484 ("b", vec!["c"]),
1485 ("c", vec!["a"]),
1486 ]);
1487 let all_nodes = vec!["a", "b", "c"];
1488 assert_eq!(detect_cycles(&all_nodes, &children), 1);
1489 }
1490
1491 #[test]
1492 fn test_depth_computation() {
1493 let children: HashMap<&str, Vec<&str>> = HashMap::from([
1494 ("root", vec!["a", "b"]),
1495 ("a", vec!["c"]),
1496 ]);
1497 let roots = vec!["root"];
1498 let (max_d, avg_d) = compute_depth(&roots, &children);
1499 assert_eq!(max_d, Some(2)); assert!(avg_d.is_some());
1501 }
1502
1503 #[test]
1504 fn test_depth_empty_roots() {
1505 let children: HashMap<&str, Vec<&str>> = HashMap::new();
1506 let roots: Vec<&str> = vec![];
1507 let (max_d, avg_d) = compute_depth(&roots, &children);
1508 assert_eq!(max_d, None);
1509 assert_eq!(avg_d, None);
1510 }
1511
1512 #[test]
1513 fn test_provenance_quality_score() {
1514 let metrics = ProvenanceMetrics {
1515 has_tool_creator: true,
1516 has_tool_version: true,
1517 has_org_creator: true,
1518 has_contact_email: true,
1519 has_serial_number: true,
1520 has_document_name: true,
1521 timestamp_age_days: 10,
1522 is_fresh: true,
1523 has_primary_component: true,
1524 lifecycle_phase: Some("build".to_string()),
1525 completeness_declaration: CompletenessDeclaration::Complete,
1526 has_signature: true,
1527 };
1528 assert_eq!(metrics.quality_score(true), 100.0);
1530 }
1531
1532 #[test]
1533 fn test_provenance_score_without_cyclonedx() {
1534 let metrics = ProvenanceMetrics {
1535 has_tool_creator: true,
1536 has_tool_version: true,
1537 has_org_creator: true,
1538 has_contact_email: true,
1539 has_serial_number: true,
1540 has_document_name: true,
1541 timestamp_age_days: 10,
1542 is_fresh: true,
1543 has_primary_component: true,
1544 lifecycle_phase: None,
1545 completeness_declaration: CompletenessDeclaration::Complete,
1546 has_signature: true,
1547 };
1548 assert_eq!(metrics.quality_score(false), 100.0);
1550 }
1551
1552 #[test]
1553 fn test_completeness_declaration_display() {
1554 assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
1555 assert_eq!(
1556 CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
1557 "incomplete (first-party only)"
1558 );
1559 assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
1560 }
1561}