Skip to main content

sbom_tools/quality/
metrics.rs

1//! Quality metrics for SBOM assessment.
2//!
3//! Provides detailed metrics for different aspects of SBOM quality.
4
5use crate::model::NormalizedSbom;
6use serde::{Deserialize, Serialize};
7
8/// Overall completeness metrics for an SBOM
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CompletenessMetrics {
11    /// Percentage of components with versions (0-100)
12    pub components_with_version: f32,
13    /// Percentage of components with PURLs (0-100)
14    pub components_with_purl: f32,
15    /// Percentage of components with CPEs (0-100)
16    pub components_with_cpe: f32,
17    /// Percentage of components with suppliers (0-100)
18    pub components_with_supplier: f32,
19    /// Percentage of components with hashes (0-100)
20    pub components_with_hashes: f32,
21    /// Percentage of components with licenses (0-100)
22    pub components_with_licenses: f32,
23    /// Percentage of components with descriptions (0-100)
24    pub components_with_description: f32,
25    /// Whether document has creator information
26    pub has_creator_info: bool,
27    /// Whether document has timestamp
28    pub has_timestamp: bool,
29    /// Whether document has serial number/ID
30    pub has_serial_number: bool,
31    /// Total component count
32    pub total_components: usize,
33}
34
35impl CompletenessMetrics {
36    /// Calculate completeness metrics from an SBOM
37    #[must_use] 
38    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
39        let total = sbom.components.len();
40        if total == 0 {
41            return Self::empty();
42        }
43
44        let mut with_version = 0;
45        let mut with_purl = 0;
46        let mut with_cpe = 0;
47        let mut with_supplier = 0;
48        let mut with_hashes = 0;
49        let mut with_licenses = 0;
50        let mut with_description = 0;
51
52        for comp in sbom.components.values() {
53            if comp.version.is_some() {
54                with_version += 1;
55            }
56            if comp.identifiers.purl.is_some() {
57                with_purl += 1;
58            }
59            if !comp.identifiers.cpe.is_empty() {
60                with_cpe += 1;
61            }
62            if comp.supplier.is_some() {
63                with_supplier += 1;
64            }
65            if !comp.hashes.is_empty() {
66                with_hashes += 1;
67            }
68            if !comp.licenses.declared.is_empty() || comp.licenses.concluded.is_some() {
69                with_licenses += 1;
70            }
71            if comp.description.is_some() {
72                with_description += 1;
73            }
74        }
75
76        let pct = |count: usize| (count as f32 / total as f32) * 100.0;
77
78        Self {
79            components_with_version: pct(with_version),
80            components_with_purl: pct(with_purl),
81            components_with_cpe: pct(with_cpe),
82            components_with_supplier: pct(with_supplier),
83            components_with_hashes: pct(with_hashes),
84            components_with_licenses: pct(with_licenses),
85            components_with_description: pct(with_description),
86            has_creator_info: !sbom.document.creators.is_empty(),
87            has_timestamp: true, // Always set in our model
88            has_serial_number: sbom.document.serial_number.is_some(),
89            total_components: total,
90        }
91    }
92
93    /// Create empty metrics
94    #[must_use] 
95    pub const fn empty() -> Self {
96        Self {
97            components_with_version: 0.0,
98            components_with_purl: 0.0,
99            components_with_cpe: 0.0,
100            components_with_supplier: 0.0,
101            components_with_hashes: 0.0,
102            components_with_licenses: 0.0,
103            components_with_description: 0.0,
104            has_creator_info: false,
105            has_timestamp: false,
106            has_serial_number: false,
107            total_components: 0,
108        }
109    }
110
111    /// Calculate overall completeness score (0-100)
112    #[must_use] 
113    pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
114        let mut score = 0.0;
115        let mut total_weight = 0.0;
116
117        // Component field scores
118        score += self.components_with_version * weights.version;
119        total_weight += weights.version * 100.0;
120
121        score += self.components_with_purl * weights.purl;
122        total_weight += weights.purl * 100.0;
123
124        score += self.components_with_cpe * weights.cpe;
125        total_weight += weights.cpe * 100.0;
126
127        score += self.components_with_supplier * weights.supplier;
128        total_weight += weights.supplier * 100.0;
129
130        score += self.components_with_hashes * weights.hashes;
131        total_weight += weights.hashes * 100.0;
132
133        score += self.components_with_licenses * weights.licenses;
134        total_weight += weights.licenses * 100.0;
135
136        // Document metadata scores
137        if self.has_creator_info {
138            score += 100.0 * weights.creator_info;
139        }
140        total_weight += weights.creator_info * 100.0;
141
142        if self.has_serial_number {
143            score += 100.0 * weights.serial_number;
144        }
145        total_weight += weights.serial_number * 100.0;
146
147        if total_weight > 0.0 {
148            (score / total_weight) * 100.0
149        } else {
150            0.0
151        }
152    }
153}
154
155/// Weights for completeness score calculation
156#[derive(Debug, Clone)]
157pub struct CompletenessWeights {
158    pub version: f32,
159    pub purl: f32,
160    pub cpe: f32,
161    pub supplier: f32,
162    pub hashes: f32,
163    pub licenses: f32,
164    pub creator_info: f32,
165    pub serial_number: f32,
166}
167
168impl Default for CompletenessWeights {
169    fn default() -> Self {
170        Self {
171            version: 1.0,
172            purl: 1.5, // Higher weight for PURL
173            cpe: 0.5,  // Lower weight, nice to have
174            supplier: 1.0,
175            hashes: 1.0,
176            licenses: 1.2, // Important for compliance
177            creator_info: 0.3,
178            serial_number: 0.2,
179        }
180    }
181}
182
183/// Identifier quality metrics
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct IdentifierMetrics {
186    /// Components with valid PURLs
187    pub valid_purls: usize,
188    /// Components with invalid/malformed PURLs
189    pub invalid_purls: usize,
190    /// Components with valid CPEs
191    pub valid_cpes: usize,
192    /// Components with invalid/malformed CPEs
193    pub invalid_cpes: usize,
194    /// Components with SWID tags
195    pub with_swid: usize,
196    /// Unique ecosystems identified
197    pub ecosystems: Vec<String>,
198    /// Components missing all identifiers (only name)
199    pub missing_all_identifiers: usize,
200}
201
202impl IdentifierMetrics {
203    /// Calculate identifier metrics from an SBOM
204    #[must_use] 
205    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
206        let mut valid_purls = 0;
207        let mut invalid_purls = 0;
208        let mut valid_cpes = 0;
209        let mut invalid_cpes = 0;
210        let mut with_swid = 0;
211        let mut missing_all = 0;
212        let mut ecosystems = std::collections::HashSet::new();
213
214        for comp in sbom.components.values() {
215            let has_purl = comp.identifiers.purl.is_some();
216            let has_cpe = !comp.identifiers.cpe.is_empty();
217            let has_swid = comp.identifiers.swid.is_some();
218
219            if let Some(ref purl) = comp.identifiers.purl {
220                if is_valid_purl(purl) {
221                    valid_purls += 1;
222                    // Extract ecosystem from PURL
223                    if let Some(eco) = extract_ecosystem_from_purl(purl) {
224                        ecosystems.insert(eco);
225                    }
226                } else {
227                    invalid_purls += 1;
228                }
229            }
230
231            for cpe in &comp.identifiers.cpe {
232                if is_valid_cpe(cpe) {
233                    valid_cpes += 1;
234                } else {
235                    invalid_cpes += 1;
236                }
237            }
238
239            if has_swid {
240                with_swid += 1;
241            }
242
243            if !has_purl && !has_cpe && !has_swid {
244                missing_all += 1;
245            }
246        }
247
248        let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
249        ecosystem_list.sort();
250
251        Self {
252            valid_purls,
253            invalid_purls,
254            valid_cpes,
255            invalid_cpes,
256            with_swid,
257            ecosystems: ecosystem_list,
258            missing_all_identifiers: missing_all,
259        }
260    }
261
262    /// Calculate identifier quality score (0-100)
263    #[must_use] 
264    pub fn quality_score(&self, total_components: usize) -> f32 {
265        if total_components == 0 {
266            return 0.0;
267        }
268
269        let with_valid_id = self.valid_purls + self.valid_cpes + self.with_swid;
270        let coverage =
271            (with_valid_id.min(total_components) as f32 / total_components as f32) * 100.0;
272
273        // Penalize invalid identifiers
274        let invalid_count = self.invalid_purls + self.invalid_cpes;
275        let penalty = (invalid_count as f32 / total_components as f32) * 20.0;
276
277        (coverage - penalty).clamp(0.0, 100.0)
278    }
279}
280
281/// License quality metrics
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct LicenseMetrics {
284    /// Components with declared licenses
285    pub with_declared: usize,
286    /// Components with concluded licenses
287    pub with_concluded: usize,
288    /// Components with valid SPDX expressions
289    pub valid_spdx_expressions: usize,
290    /// Components with non-standard license names
291    pub non_standard_licenses: usize,
292    /// Components with NOASSERTION license
293    pub noassertion_count: usize,
294    /// Unique licenses found
295    pub unique_licenses: Vec<String>,
296}
297
298impl LicenseMetrics {
299    /// Calculate license metrics from an SBOM
300    #[must_use] 
301    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
302        let mut with_declared = 0;
303        let mut with_concluded = 0;
304        let mut valid_spdx = 0;
305        let mut non_standard = 0;
306        let mut noassertion = 0;
307        let mut licenses = std::collections::HashSet::new();
308
309        for comp in sbom.components.values() {
310            if !comp.licenses.declared.is_empty() {
311                with_declared += 1;
312                for lic in &comp.licenses.declared {
313                    let expr = &lic.expression;
314                    licenses.insert(expr.clone());
315
316                    if expr == "NOASSERTION" {
317                        noassertion += 1;
318                    } else if is_valid_spdx_license(expr) {
319                        valid_spdx += 1;
320                    } else {
321                        non_standard += 1;
322                    }
323                }
324            }
325
326            if comp.licenses.concluded.is_some() {
327                with_concluded += 1;
328            }
329        }
330
331        let mut license_list: Vec<String> = licenses.into_iter().collect();
332        license_list.sort();
333
334        Self {
335            with_declared,
336            with_concluded,
337            valid_spdx_expressions: valid_spdx,
338            non_standard_licenses: non_standard,
339            noassertion_count: noassertion,
340            unique_licenses: license_list,
341        }
342    }
343
344    /// Calculate license quality score (0-100)
345    #[must_use] 
346    pub fn quality_score(&self, total_components: usize) -> f32 {
347        if total_components == 0 {
348            return 0.0;
349        }
350
351        let coverage = (self.with_declared as f32 / total_components as f32) * 60.0;
352
353        // Bonus for SPDX compliance
354        let spdx_ratio = if self.with_declared > 0 {
355            self.valid_spdx_expressions as f32 / self.with_declared as f32
356        } else {
357            0.0
358        };
359        let spdx_bonus = spdx_ratio * 30.0;
360
361        // Penalty for NOASSERTION
362        let noassertion_penalty =
363            (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
364
365        (coverage + spdx_bonus - noassertion_penalty).clamp(0.0, 100.0)
366    }
367}
368
369/// Vulnerability information quality metrics
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct VulnerabilityMetrics {
372    /// Components with vulnerability information
373    pub components_with_vulns: usize,
374    /// Total vulnerabilities reported
375    pub total_vulnerabilities: usize,
376    /// Vulnerabilities with CVSS scores
377    pub with_cvss: usize,
378    /// Vulnerabilities with CWE information
379    pub with_cwe: usize,
380    /// Vulnerabilities with remediation info
381    pub with_remediation: usize,
382    /// Components with VEX status
383    pub with_vex_status: usize,
384}
385
386impl VulnerabilityMetrics {
387    /// Calculate vulnerability metrics from an SBOM
388    #[must_use] 
389    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
390        let mut components_with_vulns = 0;
391        let mut total_vulns = 0;
392        let mut with_cvss = 0;
393        let mut with_cwe = 0;
394        let mut with_remediation = 0;
395        let mut with_vex = 0;
396
397        for comp in sbom.components.values() {
398            if !comp.vulnerabilities.is_empty() {
399                components_with_vulns += 1;
400            }
401
402            for vuln in &comp.vulnerabilities {
403                total_vulns += 1;
404
405                if !vuln.cvss.is_empty() {
406                    with_cvss += 1;
407                }
408                if !vuln.cwes.is_empty() {
409                    with_cwe += 1;
410                }
411                if vuln.remediation.is_some() {
412                    with_remediation += 1;
413                }
414            }
415
416            if comp.vex_status.is_some() {
417                with_vex += 1;
418            }
419        }
420
421        Self {
422            components_with_vulns,
423            total_vulnerabilities: total_vulns,
424            with_cvss,
425            with_cwe,
426            with_remediation,
427            with_vex_status: with_vex,
428        }
429    }
430
431    /// Calculate vulnerability documentation quality score (0-100)
432    /// Note: This measures how well vulnerabilities are documented, not how many there are
433    #[must_use] 
434    pub fn documentation_score(&self) -> f32 {
435        if self.total_vulnerabilities == 0 {
436            return 100.0; // No vulns to document
437        }
438
439        let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
440        let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
441        let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
442
443        remediation_ratio.mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0)).min(100.0)
444    }
445}
446
447/// Dependency graph quality metrics
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct DependencyMetrics {
450    /// Total dependency relationships
451    pub total_dependencies: usize,
452    /// Components with at least one dependency
453    pub components_with_deps: usize,
454    /// Maximum dependency depth (if calculable)
455    pub max_depth: Option<usize>,
456    /// Orphan components (no incoming or outgoing deps)
457    pub orphan_components: usize,
458    /// Root components (no incoming deps)
459    pub root_components: usize,
460}
461
462impl DependencyMetrics {
463    /// Calculate dependency metrics from an SBOM
464    #[must_use] 
465    pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
466        let total_deps = sbom.edges.len();
467
468        let mut has_outgoing = std::collections::HashSet::new();
469        let mut has_incoming = std::collections::HashSet::new();
470
471        for edge in &sbom.edges {
472            has_outgoing.insert(&edge.from);
473            has_incoming.insert(&edge.to);
474        }
475
476        let all_components: std::collections::HashSet<_> = sbom.components.keys().collect();
477
478        let orphans = all_components
479            .iter()
480            .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
481            .count();
482
483        let roots = has_outgoing
484            .iter()
485            .filter(|c| !has_incoming.contains(*c))
486            .count();
487
488        Self {
489            total_dependencies: total_deps,
490            components_with_deps: has_outgoing.len(),
491            max_depth: None, // Would require graph traversal
492            orphan_components: orphans,
493            root_components: roots,
494        }
495    }
496
497    /// Calculate dependency graph quality score (0-100)
498    #[must_use] 
499    pub fn quality_score(&self, total_components: usize) -> f32 {
500        if total_components == 0 {
501            return 0.0;
502        }
503
504        // Score based on how many components have dependency info
505        let coverage = if total_components > 1 {
506            (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
507        } else {
508            100.0 // Single component SBOM
509        };
510
511        // Slight penalty for orphan components (except for root)
512        let orphan_ratio = self.orphan_components as f32 / total_components as f32;
513        let penalty = orphan_ratio * 10.0;
514
515        (coverage - penalty).clamp(0.0, 100.0)
516    }
517}
518
519// Helper functions
520
521fn is_valid_purl(purl: &str) -> bool {
522    // Basic PURL validation: pkg:type/namespace/name@version
523    purl.starts_with("pkg:") && purl.contains('/')
524}
525
526fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
527    // Extract type from pkg:type/...
528    if let Some(rest) = purl.strip_prefix("pkg:") {
529        if let Some(slash_idx) = rest.find('/') {
530            return Some(rest[..slash_idx].to_string());
531        }
532    }
533    None
534}
535
536fn is_valid_cpe(cpe: &str) -> bool {
537    // Basic CPE validation
538    cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
539}
540
541fn is_valid_spdx_license(expr: &str) -> bool {
542    // Common SPDX license identifiers
543    const COMMON_SPDX: &[&str] = &[
544        "MIT",
545        "Apache-2.0",
546        "GPL-2.0",
547        "GPL-3.0",
548        "BSD-2-Clause",
549        "BSD-3-Clause",
550        "ISC",
551        "MPL-2.0",
552        "LGPL-2.1",
553        "LGPL-3.0",
554        "AGPL-3.0",
555        "Unlicense",
556        "CC0-1.0",
557        "0BSD",
558        "EPL-2.0",
559        "CDDL-1.0",
560        "Artistic-2.0",
561        "GPL-2.0-only",
562        "GPL-2.0-or-later",
563        "GPL-3.0-only",
564        "GPL-3.0-or-later",
565        "LGPL-2.1-only",
566        "LGPL-2.1-or-later",
567        "LGPL-3.0-only",
568        "LGPL-3.0-or-later",
569    ];
570
571    // Check for common licenses or expressions
572    let trimmed = expr.trim();
573    COMMON_SPDX.contains(&trimmed)
574        || trimmed.contains(" AND ")
575        || trimmed.contains(" OR ")
576        || trimmed.contains(" WITH ")
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_purl_validation() {
585        assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
586        assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
587        assert!(!is_valid_purl("npm:something"));
588        assert!(!is_valid_purl("invalid"));
589    }
590
591    #[test]
592    fn test_cpe_validation() {
593        assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
594        assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
595        assert!(!is_valid_cpe("something:else"));
596    }
597
598    #[test]
599    fn test_spdx_license_validation() {
600        assert!(is_valid_spdx_license("MIT"));
601        assert!(is_valid_spdx_license("Apache-2.0"));
602        assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
603        assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
604    }
605}