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