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 #[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, has_serial_number: sbom.document.serial_number.is_some(),
89 total_components: total,
90 }
91 }
92
93 #[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 #[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 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 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#[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, cpe: 0.5, supplier: 1.0,
175 hashes: 1.0,
176 licenses: 1.2, creator_info: 0.3,
178 serial_number: 0.2,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct IdentifierMetrics {
186 pub valid_purls: usize,
188 pub invalid_purls: usize,
190 pub valid_cpes: usize,
192 pub invalid_cpes: usize,
194 pub with_swid: usize,
196 pub ecosystems: Vec<String>,
198 pub missing_all_identifiers: usize,
200}
201
202impl IdentifierMetrics {
203 #[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 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 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct LicenseMetrics {
284 pub with_declared: usize,
286 pub with_concluded: usize,
288 pub valid_spdx_expressions: usize,
290 pub non_standard_licenses: usize,
292 pub noassertion_count: usize,
294 pub unique_licenses: Vec<String>,
296}
297
298impl LicenseMetrics {
299 #[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 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct VulnerabilityMetrics {
372 pub components_with_vulns: usize,
374 pub total_vulnerabilities: usize,
376 pub with_cvss: usize,
378 pub with_cwe: usize,
380 pub with_remediation: usize,
382 pub with_vex_status: usize,
384}
385
386impl VulnerabilityMetrics {
387 #[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 #[must_use]
434 pub fn documentation_score(&self) -> f32 {
435 if self.total_vulnerabilities == 0 {
436 return 100.0; }
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#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct DependencyMetrics {
450 pub total_dependencies: usize,
452 pub components_with_deps: usize,
454 pub max_depth: Option<usize>,
456 pub orphan_components: usize,
458 pub root_components: usize,
460}
461
462impl DependencyMetrics {
463 #[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, orphan_components: orphans,
493 root_components: roots,
494 }
495 }
496
497 #[must_use]
499 pub fn quality_score(&self, total_components: usize) -> f32 {
500 if total_components == 0 {
501 return 0.0;
502 }
503
504 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 };
510
511 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
519fn is_valid_purl(purl: &str) -> bool {
522 purl.starts_with("pkg:") && purl.contains('/')
524}
525
526fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
527 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 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
539}
540
541fn is_valid_spdx_license(expr: &str) -> bool {
542 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 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}