1use crate::model::{CreatorType, ExternalRefType, HashAlgorithm, NormalizedSbom, Severity};
6use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
7use crate::quality::{
8 ComplianceChecker, ComplianceLevel, ComplianceResult, Violation, ViolationCategory,
9 ViolationSeverity,
10};
11use crate::reports::{ReportFormat, generate_compliance_sarif};
12use anyhow::{Result, bail};
13use std::collections::HashSet;
14use std::path::PathBuf;
15
16#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
28pub fn run_validate(
29 sbom_path: PathBuf,
30 standard: String,
31 output: ReportFormat,
32 output_file: Option<PathBuf>,
33 fail_on_warning: bool,
34 summary: bool,
35 cra_sidecar_path: Option<PathBuf>,
36 cra_product_class: Option<String>,
37) -> Result<i32> {
38 let parsed = parse_sbom_with_context(&sbom_path, false)?;
39
40 let cra_sidecar = match cra_sidecar_path {
42 Some(p) => Some(
43 crate::model::CraSidecarMetadata::from_file(&p).map_err(|e| {
44 anyhow::anyhow!("Failed to load CRA sidecar from {}: {e}", p.display())
45 })?,
46 ),
47 None => crate::model::CraSidecarMetadata::find_for_sbom(&sbom_path),
48 };
49
50 let cli_class = cra_product_class
54 .as_deref()
55 .and_then(crate::model::CraProductClass::parse_cli);
56 let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
57 if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
58 && cli != side
59 {
60 tracing::warn!(
61 "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
62 cli.label(),
63 side.label()
64 );
65 }
66 let effective_class = sidecar_class.or(cli_class);
67
68 let standards: Vec<&str> = standard.split(',').map(str::trim).collect();
69 let mut results = Vec::new();
70
71 for std_name in &standards {
72 let result = match std_name.to_lowercase().as_str() {
73 "ntia" => check_ntia_compliance(parsed.sbom()),
74 "fda" => check_fda_compliance(parsed.sbom()),
75 "cra" => {
76 let mut checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
77 if let Some(sc) = cra_sidecar.clone() {
78 checker = checker.with_sidecar(sc);
79 }
80 if let Some(c) = effective_class {
81 checker = checker.with_product_class(c);
82 }
83 checker.check(parsed.sbom())
84 }
85 "ssdf" | "nist-ssdf" | "nist_ssdf" => {
86 ComplianceChecker::new(ComplianceLevel::NistSsdf).check(parsed.sbom())
87 }
88 "eo14028" | "eo-14028" | "eo_14028" => {
89 ComplianceChecker::new(ComplianceLevel::Eo14028).check(parsed.sbom())
90 }
91 "cnsa2" | "cnsa-2" | "cnsa_2" | "cnsa2.0" => {
92 ComplianceChecker::new(ComplianceLevel::Cnsa2).check(parsed.sbom())
93 }
94 "pqc" | "nist-pqc" | "nist_pqc" => {
95 ComplianceChecker::new(ComplianceLevel::NistPqc).check(parsed.sbom())
96 }
97 "bsi" | "tr-03183" | "tr03183" | "bsi-tr-03183-2" => {
98 ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(parsed.sbom())
99 }
100 "oss-steward" | "cra-oss-steward" | "cra-oss" | "cra-art24" | "art24" => {
101 let mut checker = ComplianceChecker::new(ComplianceLevel::CraOssSteward);
102 if let Some(sc) = cra_sidecar.clone() {
103 checker = checker.with_sidecar(sc);
104 }
105 checker.check(parsed.sbom())
106 }
107 "eucc" | "eucc-substantial" | "common-criteria" => {
108 let mut checker = ComplianceChecker::new(ComplianceLevel::EuccSubstantial);
109 if let Some(sc) = cra_sidecar.clone() {
110 checker = checker.with_sidecar(sc);
111 }
112 checker.check(parsed.sbom())
113 }
114 "ai-act" | "ai_act" | "aiact" | "eu-ai-act" => {
115 let mut checker = ComplianceChecker::new(ComplianceLevel::EuAiAct);
118 if let Some(sc) = cra_sidecar.clone() {
119 checker = checker.with_sidecar(sc);
120 }
121 checker.check(parsed.sbom())
122 }
123 "bsi-ai" | "bsi_ai" | "bsiai" | "sbom-for-ai" | "ai-bom" => {
124 ComplianceChecker::new(ComplianceLevel::BsiSbomForAi).check(parsed.sbom())
125 }
126 _ => {
127 bail!(
128 "Unknown validation standard: {std_name}. \
129 Valid options: ntia, fda, cra, ssdf, eo14028, cnsa2, pqc, bsi, oss-steward, eucc, ai-act, bsi-ai"
130 );
131 }
132 };
133 results.push(result);
134 }
135
136 if results.len() == 1 {
137 let result = &results[0];
138 if summary {
139 write_compliance_summary(result, output_file)?;
140 } else {
141 write_compliance_output(result, output, output_file)?;
142 }
143
144 if result.error_count > 0 {
145 return Ok(exit_codes::COMPLIANCE_ERRORS);
146 }
147 if fail_on_warning && result.warning_count > 0 {
148 return Ok(exit_codes::COMPLIANCE_WARNINGS);
149 }
150 } else {
151 if summary {
153 write_multi_compliance_summary(&results, output_file)?;
154 } else {
155 write_multi_compliance_output(&results, output, output_file)?;
156 }
157
158 let has_errors = results.iter().any(|r| r.error_count > 0);
159 let has_warnings = results.iter().any(|r| r.warning_count > 0);
160 if has_errors {
161 return Ok(exit_codes::COMPLIANCE_ERRORS);
162 }
163 if fail_on_warning && has_warnings {
164 return Ok(exit_codes::COMPLIANCE_WARNINGS);
165 }
166 }
167
168 Ok(exit_codes::SUCCESS)
169}
170
171fn write_compliance_output(
172 result: &ComplianceResult,
173 output: ReportFormat,
174 output_file: Option<PathBuf>,
175) -> Result<()> {
176 let target = OutputTarget::from_option(output_file);
177
178 let content = match output {
179 ReportFormat::Json => serde_json::to_string_pretty(result)
180 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
181 ReportFormat::Sarif => generate_compliance_sarif(result)?,
182 _ => format_compliance_text(result),
183 };
184
185 write_output(&content, &target, false)?;
186 Ok(())
187}
188
189#[derive(serde::Serialize)]
191struct ComplianceSummary {
192 standard: String,
193 compliant: bool,
194 score: u8,
195 errors: usize,
196 warnings: usize,
197 info: usize,
198}
199
200fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
201 let target = OutputTarget::from_option(output_file);
202 let total = result.violations.len() + 1;
203 let issues = result.error_count + result.warning_count;
204 let score = if issues >= total {
205 0
206 } else {
207 ((total - issues) * 100) / total
208 }
209 .min(100) as u8;
210
211 let summary = ComplianceSummary {
212 standard: result.level.name().to_string(),
213 compliant: result.is_compliant,
214 score,
215 errors: result.error_count,
216 warnings: result.warning_count,
217 info: result.info_count,
218 };
219 let content = serde_json::to_string(&summary)
220 .map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
221 write_output(&content, &target, false)?;
222 Ok(())
223}
224
225fn write_multi_compliance_output(
226 results: &[ComplianceResult],
227 output: ReportFormat,
228 output_file: Option<PathBuf>,
229) -> Result<()> {
230 let target = OutputTarget::from_option(output_file);
231
232 let content = match output {
233 ReportFormat::Json => serde_json::to_string_pretty(results)
234 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
235 ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
236 _ => {
237 let mut parts = Vec::new();
238 for result in results {
239 parts.push(format_compliance_text(result));
240 }
241 parts.join("\n---\n\n")
242 }
243 };
244
245 write_output(&content, &target, false)?;
246 Ok(())
247}
248
249fn write_multi_compliance_summary(
250 results: &[ComplianceResult],
251 output_file: Option<PathBuf>,
252) -> Result<()> {
253 let target = OutputTarget::from_option(output_file);
254 let summaries: Vec<ComplianceSummary> = results
255 .iter()
256 .map(|result| {
257 let total = result.violations.len() + 1;
258 let issues = result.error_count + result.warning_count;
259 let score = if issues >= total {
260 0
261 } else {
262 ((total - issues) * 100) / total
263 }
264 .min(100) as u8;
265
266 ComplianceSummary {
267 standard: result.level.name().to_string(),
268 compliant: result.is_compliant,
269 score,
270 errors: result.error_count,
271 warnings: result.warning_count,
272 info: result.info_count,
273 }
274 })
275 .collect();
276
277 let content = serde_json::to_string(&summaries)
278 .map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
279 write_output(&content, &target, false)?;
280 Ok(())
281}
282
283fn format_compliance_text(result: &ComplianceResult) -> String {
284 let mut lines = Vec::new();
285 lines.push(format!("Compliance ({})", result.level.name()));
286 lines.push(format!(
287 "Status: {} ({} errors, {} warnings, {} info)",
288 if result.is_compliant {
289 "COMPLIANT"
290 } else {
291 "NON-COMPLIANT"
292 },
293 result.error_count,
294 result.warning_count,
295 result.info_count
296 ));
297 lines.push(String::new());
298
299 if result.violations.is_empty() {
300 lines.push("No violations found.".to_string());
301 return lines.join("\n");
302 }
303
304 for v in &result.violations {
305 let severity = match v.severity {
306 ViolationSeverity::Error => "ERROR",
307 ViolationSeverity::Warning => "WARN",
308 ViolationSeverity::Info => "INFO",
309 };
310 let element = v.element.as_deref().unwrap_or("-");
311 lines.push(format!(
312 "[{}] {} | {} | {}",
313 severity,
314 v.category.name(),
315 v.requirement,
316 element
317 ));
318 lines.push(format!(" {}", v.message));
319 }
320
321 lines.join("\n")
322}
323
324fn check_ntia_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
326 let mut violations = Vec::new();
327
328 if sbom.document.creators.is_empty() {
329 violations.push(Violation {
330 severity: ViolationSeverity::Error,
331 category: ViolationCategory::DocumentMetadata,
332 message: "Missing author/creator information".to_string(),
333 element: None,
334 requirement: "NTIA Minimum Elements: Author".to_string(),
335 rule_id: "SBOM-NTIA-AUTHOR",
336 standard_refs: Vec::new(),
337 });
338 }
339
340 for (_id, comp) in &sbom.components {
341 if comp.name.is_empty() {
342 violations.push(Violation {
343 severity: ViolationSeverity::Error,
344 category: ViolationCategory::ComponentIdentification,
345 message: "Component missing name".to_string(),
346 element: None,
347 requirement: "NTIA Minimum Elements: Component Name".to_string(),
348 rule_id: "SBOM-NTIA-NAME",
349 standard_refs: Vec::new(),
350 });
351 }
352 if comp.version.is_none() {
353 violations.push(Violation {
354 severity: ViolationSeverity::Warning,
355 category: ViolationCategory::ComponentIdentification,
356 message: format!("Component '{}' missing version", comp.name),
357 element: Some(comp.name.clone()),
358 requirement: "NTIA Minimum Elements: Version".to_string(),
359 rule_id: "SBOM-NTIA-VERSION",
360 standard_refs: Vec::new(),
361 });
362 }
363 if comp.supplier.is_none() {
364 violations.push(Violation {
365 severity: ViolationSeverity::Warning,
366 category: ViolationCategory::SupplierInfo,
367 message: format!("Component '{}' missing supplier", comp.name),
368 element: Some(comp.name.clone()),
369 requirement: "NTIA Minimum Elements: Supplier Name".to_string(),
370 rule_id: "SBOM-NTIA-SUPPLIER",
371 standard_refs: Vec::new(),
372 });
373 }
374 if comp.identifiers.purl.is_none()
375 && comp.identifiers.cpe.is_empty()
376 && comp.identifiers.swid.is_none()
377 {
378 violations.push(Violation {
379 severity: ViolationSeverity::Warning,
380 category: ViolationCategory::ComponentIdentification,
381 message: format!(
382 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
383 comp.name
384 ),
385 element: Some(comp.name.clone()),
386 requirement: "NTIA Minimum Elements: Unique Identifier".to_string(),
387 rule_id: "SBOM-NTIA-IDENTIFIER",
388 standard_refs: Vec::new(),
389 });
390 }
391 }
392
393 if sbom.edges.is_empty() && sbom.component_count() > 1 {
394 violations.push(Violation {
395 severity: ViolationSeverity::Error,
396 category: ViolationCategory::DependencyInfo,
397 message: "Missing dependency relationships".to_string(),
398 element: None,
399 requirement: "NTIA Minimum Elements: Dependency Relationship".to_string(),
400 rule_id: "SBOM-NTIA-DEPENDENCY",
401 standard_refs: Vec::new(),
402 });
403 }
404
405 ComplianceResult::new(ComplianceLevel::NtiaMinimum, violations)
406}
407
408fn check_fda_compliance(sbom: &NormalizedSbom) -> ComplianceResult {
410 let mut fda_issues: Vec<FdaIssue> = Vec::new();
411
412 validate_fda_document(sbom, &mut fda_issues);
413 validate_fda_components(sbom, &mut fda_issues);
414 validate_fda_relationships(sbom, &mut fda_issues);
415 validate_fda_vulnerabilities(sbom, &mut fda_issues);
416
417 let violations = fda_issues
418 .into_iter()
419 .map(|issue| Violation {
420 severity: match issue.severity {
421 FdaSeverity::Error => ViolationSeverity::Error,
422 FdaSeverity::Warning => ViolationSeverity::Warning,
423 FdaSeverity::Info => ViolationSeverity::Info,
424 },
425 category: match issue.category {
426 "Document" => ViolationCategory::DocumentMetadata,
427 "Component" => ViolationCategory::ComponentIdentification,
428 "Dependency" => ViolationCategory::DependencyInfo,
429 "Security" => ViolationCategory::SecurityInfo,
430 _ => ViolationCategory::DocumentMetadata,
431 },
432 requirement: format!("FDA Medical Device: {}", issue.category),
433 message: issue.message,
434 element: None,
435 rule_id: match issue.category {
436 "Dependency" => "SBOM-FDA-DEPENDENCY",
437 "Security" => "SBOM-FDA-SECURITY",
438 _ => "SBOM-FDA-GENERAL",
439 },
440 standard_refs: Vec::new(),
441 })
442 .collect();
443
444 ComplianceResult::new(ComplianceLevel::FdaMedicalDevice, violations)
445}
446
447#[allow(clippy::unnecessary_wraps)]
449pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
450 let mut issues = Vec::new();
451
452 if sbom.document.creators.is_empty() {
454 issues.push("Missing author/creator information");
455 }
456
457 for (_id, comp) in &sbom.components {
459 if comp.name.is_empty() {
460 issues.push("Component missing name");
461 }
462 if comp.version.is_none() {
463 tracing::warn!("Component '{}' missing version", comp.name);
464 }
465 if comp.supplier.is_none() {
466 tracing::warn!("Component '{}' missing supplier", comp.name);
467 }
468 if comp.identifiers.purl.is_none()
469 && comp.identifiers.cpe.is_empty()
470 && comp.identifiers.swid.is_none()
471 {
472 tracing::warn!(
473 "Component '{}' missing unique identifier (PURL/CPE/SWID)",
474 comp.name
475 );
476 }
477 }
478
479 if sbom.edges.is_empty() && sbom.component_count() > 1 {
480 issues.push("Missing dependency relationships");
481 }
482
483 if issues.is_empty() {
484 tracing::info!("SBOM passes NTIA minimum elements validation");
485 println!("NTIA Validation: PASSED");
486 } else {
487 tracing::warn!("SBOM has {} NTIA validation issues", issues.len());
488 println!("NTIA Validation: FAILED");
489 for issue in &issues {
490 println!(" - {issue}");
491 }
492 }
493
494 Ok(())
495}
496
497#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
499enum FdaSeverity {
500 Error, Warning, Info, }
504
505impl std::fmt::Display for FdaSeverity {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 match self {
508 Self::Error => write!(f, "ERROR"),
509 Self::Warning => write!(f, "WARNING"),
510 Self::Info => write!(f, "INFO"),
511 }
512 }
513}
514
515struct FdaIssue {
517 severity: FdaSeverity,
518 category: &'static str,
519 message: String,
520}
521
522struct ComponentStats {
524 total: usize,
525 without_version: usize,
526 without_supplier: usize,
527 without_hash: usize,
528 without_strong_hash: usize,
529 without_identifier: usize,
530 without_support_info: usize,
531}
532
533fn validate_fda_document(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
534 if sbom.document.creators.is_empty() {
536 issues.push(FdaIssue {
537 severity: FdaSeverity::Error,
538 category: "Document",
539 message: "Missing SBOM author/manufacturer information".to_string(),
540 });
541 } else {
542 let has_org = sbom
543 .document
544 .creators
545 .iter()
546 .any(|c| c.creator_type == CreatorType::Organization);
547 if !has_org {
548 issues.push(FdaIssue {
549 severity: FdaSeverity::Warning,
550 category: "Document",
551 message: "No organization/manufacturer listed as SBOM creator".to_string(),
552 });
553 }
554
555 let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
556 if !has_contact {
557 issues.push(FdaIssue {
558 severity: FdaSeverity::Warning,
559 category: "Document",
560 message: "No contact email provided for SBOM creators".to_string(),
561 });
562 }
563 }
564
565 if sbom.document.name.is_none() {
567 issues.push(FdaIssue {
568 severity: FdaSeverity::Warning,
569 category: "Document",
570 message: "Missing SBOM document name/title".to_string(),
571 });
572 }
573
574 if sbom.document.serial_number.is_none() {
576 issues.push(FdaIssue {
577 severity: FdaSeverity::Warning,
578 category: "Document",
579 message: "Missing SBOM serial number or document namespace".to_string(),
580 });
581 }
582}
583
584fn validate_fda_components(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) -> ComponentStats {
585 let mut stats = ComponentStats {
586 total: sbom.component_count(),
587 without_version: 0,
588 without_supplier: 0,
589 without_hash: 0,
590 without_strong_hash: 0,
591 without_identifier: 0,
592 without_support_info: 0,
593 };
594
595 for (_id, comp) in &sbom.components {
596 if comp.name.is_empty() {
597 issues.push(FdaIssue {
598 severity: FdaSeverity::Error,
599 category: "Component",
600 message: "Component has empty name".to_string(),
601 });
602 }
603
604 if comp.version.is_none() {
605 stats.without_version += 1;
606 }
607
608 if comp.supplier.is_none() {
609 stats.without_supplier += 1;
610 }
611
612 if comp.hashes.is_empty() {
613 stats.without_hash += 1;
614 } else {
615 let has_strong_hash = comp.hashes.iter().any(|h| {
616 matches!(
617 h.algorithm,
618 HashAlgorithm::Sha256
619 | HashAlgorithm::Sha384
620 | HashAlgorithm::Sha512
621 | HashAlgorithm::Sha3_256
622 | HashAlgorithm::Sha3_384
623 | HashAlgorithm::Sha3_512
624 | HashAlgorithm::Blake2b256
625 | HashAlgorithm::Blake2b384
626 | HashAlgorithm::Blake2b512
627 | HashAlgorithm::Blake3
628 )
629 });
630 if !has_strong_hash {
631 stats.without_strong_hash += 1;
632 }
633 }
634
635 if comp.identifiers.purl.is_none()
636 && comp.identifiers.cpe.is_empty()
637 && comp.identifiers.swid.is_none()
638 {
639 stats.without_identifier += 1;
640 }
641
642 let has_support_info = comp.external_refs.iter().any(|r| {
643 matches!(
644 r.ref_type,
645 ExternalRefType::Support
646 | ExternalRefType::Website
647 | ExternalRefType::SecurityContact
648 | ExternalRefType::Advisories
649 )
650 });
651 if !has_support_info {
652 stats.without_support_info += 1;
653 }
654 }
655
656 if stats.without_version > 0 {
658 issues.push(FdaIssue {
659 severity: FdaSeverity::Error,
660 category: "Component",
661 message: format!(
662 "{}/{} components missing version information",
663 stats.without_version, stats.total
664 ),
665 });
666 }
667
668 if stats.without_supplier > 0 {
669 issues.push(FdaIssue {
670 severity: FdaSeverity::Error,
671 category: "Component",
672 message: format!(
673 "{}/{} components missing supplier/manufacturer information",
674 stats.without_supplier, stats.total
675 ),
676 });
677 }
678
679 if stats.without_hash > 0 {
680 issues.push(FdaIssue {
681 severity: FdaSeverity::Error,
682 category: "Component",
683 message: format!(
684 "{}/{} components missing cryptographic hash",
685 stats.without_hash, stats.total
686 ),
687 });
688 }
689
690 if stats.without_strong_hash > 0 {
691 issues.push(FdaIssue {
692 severity: FdaSeverity::Warning,
693 category: "Component",
694 message: format!(
695 "{}/{} components have only weak hash algorithms (MD5/SHA-1). FDA recommends SHA-256 or stronger",
696 stats.without_strong_hash, stats.total
697 ),
698 });
699 }
700
701 if stats.without_identifier > 0 {
702 issues.push(FdaIssue {
703 severity: FdaSeverity::Error,
704 category: "Component",
705 message: format!(
706 "{}/{} components missing unique identifier (PURL/CPE/SWID)",
707 stats.without_identifier, stats.total
708 ),
709 });
710 }
711
712 if stats.without_support_info > 0 && stats.total > 0 {
713 let percentage = (stats.without_support_info as f64 / stats.total as f64) * 100.0;
714 if percentage > 50.0 {
715 issues.push(FdaIssue {
716 severity: FdaSeverity::Info,
717 category: "Component",
718 message: format!(
719 "{}/{} components ({:.0}%) lack support/contact information",
720 stats.without_support_info, stats.total, percentage
721 ),
722 });
723 }
724 }
725
726 stats
727}
728
729fn validate_fda_relationships(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
730 let total = sbom.component_count();
731
732 if sbom.edges.is_empty() && total > 1 {
733 issues.push(FdaIssue {
734 severity: FdaSeverity::Error,
735 category: "Dependency",
736 message: format!("No dependency relationships defined for {total} components"),
737 });
738 }
739
740 if !sbom.edges.is_empty() {
742 let mut connected: HashSet<String> = HashSet::new();
743 for edge in &sbom.edges {
744 connected.insert(edge.from.value().to_string());
745 connected.insert(edge.to.value().to_string());
746 }
747 let orphan_count = sbom
748 .components
749 .keys()
750 .filter(|id| !connected.contains(id.value()))
751 .count();
752
753 if orphan_count > 0 && orphan_count < total {
754 issues.push(FdaIssue {
755 severity: FdaSeverity::Warning,
756 category: "Dependency",
757 message: format!(
758 "{orphan_count}/{total} components have no dependency relationships (orphaned)"
759 ),
760 });
761 }
762 }
763}
764
765fn validate_fda_vulnerabilities(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
766 let vuln_info = sbom.all_vulnerabilities();
767 if !vuln_info.is_empty() {
768 let critical_vulns = vuln_info
769 .iter()
770 .filter(|(_, v)| matches!(v.severity, Some(Severity::Critical)))
771 .count();
772 let high_vulns = vuln_info
773 .iter()
774 .filter(|(_, v)| matches!(v.severity, Some(Severity::High)))
775 .count();
776
777 if critical_vulns > 0 || high_vulns > 0 {
778 issues.push(FdaIssue {
779 severity: FdaSeverity::Warning,
780 category: "Security",
781 message: format!(
782 "SBOM contains {critical_vulns} critical and {high_vulns} high severity vulnerabilities"
783 ),
784 });
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn test_fda_severity_order() {
795 assert!(FdaSeverity::Error < FdaSeverity::Warning);
796 assert!(FdaSeverity::Warning < FdaSeverity::Info);
797 }
798
799 #[test]
800 fn test_fda_severity_display() {
801 assert_eq!(format!("{}", FdaSeverity::Error), "ERROR");
802 assert_eq!(format!("{}", FdaSeverity::Warning), "WARNING");
803 assert_eq!(format!("{}", FdaSeverity::Info), "INFO");
804 }
805
806 #[test]
807 fn test_validate_empty_sbom() {
808 let sbom = NormalizedSbom::default();
809 let _ = validate_ntia_elements(&sbom);
811 }
812
813 #[test]
814 fn test_fda_document_validation() {
815 let sbom = NormalizedSbom::default();
816 let mut issues = Vec::new();
817 validate_fda_document(&sbom, &mut issues);
818 assert!(!issues.is_empty());
820 }
821}