1use crate::model::NormalizedSbom;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CompletenessMetrics {
11 pub components_with_version: f32,
13 pub components_with_purl: f32,
15 pub components_with_cpe: f32,
17 pub components_with_supplier: f32,
19 pub components_with_hashes: f32,
21 pub components_with_licenses: f32,
23 pub components_with_description: f32,
25 pub has_creator_info: bool,
27 pub has_timestamp: bool,
29 pub has_serial_number: bool,
31 pub total_components: usize,
33}
34
35impl CompletenessMetrics {
36 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, has_serial_number: sbom.document.serial_number.is_some(),
88 total_components: total,
89 }
90 }
91
92 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 pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
111 let mut score = 0.0;
112 let mut total_weight = 0.0;
113
114 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 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#[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, cpe: 0.5, supplier: 1.0,
172 hashes: 1.0,
173 licenses: 1.2, creator_info: 0.3,
175 serial_number: 0.2,
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct IdentifierMetrics {
183 pub valid_purls: usize,
185 pub invalid_purls: usize,
187 pub valid_cpes: usize,
189 pub invalid_cpes: usize,
191 pub with_swid: usize,
193 pub ecosystems: Vec<String>,
195 pub missing_all_identifiers: usize,
197}
198
199impl IdentifierMetrics {
200 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct LicenseMetrics {
279 pub with_declared: usize,
281 pub with_concluded: usize,
283 pub valid_spdx_expressions: usize,
285 pub non_standard_licenses: usize,
287 pub noassertion_count: usize,
289 pub unique_licenses: Vec<String>,
291}
292
293impl LicenseMetrics {
294 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct VulnerabilityMetrics {
365 pub components_with_vulns: usize,
367 pub total_vulnerabilities: usize,
369 pub with_cvss: usize,
371 pub with_cwe: usize,
373 pub with_remediation: usize,
375 pub with_vex_status: usize,
377}
378
379impl VulnerabilityMetrics {
380 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 pub fn documentation_score(&self) -> f32 {
426 if self.total_vulnerabilities == 0 {
427 return 100.0; }
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#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct DependencyMetrics {
441 pub total_dependencies: usize,
443 pub components_with_deps: usize,
445 pub max_depth: Option<usize>,
447 pub orphan_components: usize,
449 pub root_components: usize,
451}
452
453impl DependencyMetrics {
454 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, orphan_components: orphans,
483 root_components: roots,
484 }
485 }
486
487 pub fn quality_score(&self, total_components: usize) -> f32 {
489 if total_components == 0 {
490 return 0.0;
491 }
492
493 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 };
499
500 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
508fn is_valid_purl(purl: &str) -> bool {
511 purl.starts_with("pkg:") && purl.contains('/')
513}
514
515fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
516 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 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
528}
529
530fn is_valid_spdx_license(expr: &str) -> bool {
531 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 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}