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, 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/// Run the validate command, returning the desired exit code.
17///
18/// # Exit codes
19/// - [`exit_codes::SUCCESS`] (0): compliant (no errors; no warnings when
20///   `--fail-on-warning` is set)
21/// - [`exit_codes::COMPLIANCE_ERRORS`] (1): one or more compliance errors found
22/// - [`exit_codes::COMPLIANCE_WARNINGS`] (2): warnings found with
23///   `--fail-on-warning`
24///
25/// The caller is responsible for calling `std::process::exit()` with the
26/// returned code when it is non-zero.
27#[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    // Load CRA sidecar — explicit flag wins, otherwise auto-discover next to the SBOM
41    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    // Resolve effective product class: sidecar wins; otherwise CLI flag.
51    // Mismatch between explicit CLI flag and sidecar is reported as a Warning
52    // on stderr (not turned into a Violation — sidecar is authoritative).
53    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                // The sidecar carries the is_high_risk_ai flag, which escalates
116                // Annex IV readiness findings — attach it when present.
117                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        // Multi-standard: merge results for output
152        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/// Compact summary for CI badge generation
190#[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
324/// Check SBOM against NTIA minimum elements, returning a structured result.
325fn 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
408/// Check SBOM against FDA medical device requirements, returning a structured result.
409fn 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/// Validate SBOM against NTIA minimum elements
448#[allow(clippy::unnecessary_wraps)]
449pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
450    let mut issues = Vec::new();
451
452    // Check document-level requirements
453    if sbom.document.creators.is_empty() {
454        issues.push("Missing author/creator information");
455    }
456
457    // Check component-level requirements
458    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/// FDA validation issue severity
498#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
499enum FdaSeverity {
500    Error,   // Must fix - will likely cause FDA rejection
501    Warning, // Should fix - may cause FDA questions
502    Info,    // Recommendation for improvement
503}
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
515/// FDA validation issue
516struct FdaIssue {
517    severity: FdaSeverity,
518    category: &'static str,
519    message: String,
520}
521
522/// Component validation statistics
523struct 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    // Manufacturer/Author Information
535    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    // SBOM Name/Title
566    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    // Serial Number/Namespace
575    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    // Add component issues
657    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    // Check for orphan components
741    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        // Should not panic
810        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        // Should find missing creator issue
819        assert!(!issues.is_empty());
820    }
821}