Skip to main content

sbom_tools/cli/
validate.rs

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