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::{parse_sbom_with_context, write_output, OutputTarget};
7use crate::quality::{ComplianceChecker, ComplianceLevel, ComplianceResult, ViolationSeverity};
8use crate::reports::{generate_compliance_sarif, ReportFormat};
9use anyhow::{bail, Result};
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13/// Run the validate command
14#[allow(clippy::needless_pass_by_value)]
15pub fn run_validate(
16    sbom_path: PathBuf,
17    standard: String,
18    output: ReportFormat,
19    output_file: Option<PathBuf>,
20) -> Result<()> {
21    let parsed = parse_sbom_with_context(&sbom_path, false)?;
22
23    match standard.to_lowercase().as_str() {
24        "ntia" => validate_ntia_elements(parsed.sbom())?,
25        "fda" => validate_fda_elements(parsed.sbom())?,
26        "cra" => {
27            let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
28            let result = checker.check(parsed.sbom());
29            write_compliance_output(&result, output, output_file)?;
30        }
31        _ => {
32            bail!("Unknown validation standard: {standard}");
33        }
34    }
35
36    Ok(())
37}
38
39fn write_compliance_output(
40    result: &ComplianceResult,
41    output: ReportFormat,
42    output_file: Option<PathBuf>,
43) -> Result<()> {
44    let target = OutputTarget::from_option(output_file);
45
46    let content = match output {
47        ReportFormat::Json => serde_json::to_string_pretty(result)
48            .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
49        ReportFormat::Sarif => generate_compliance_sarif(result)?,
50        _ => format_compliance_text(result),
51    };
52
53    write_output(&content, &target, false)?;
54    Ok(())
55}
56
57fn format_compliance_text(result: &ComplianceResult) -> String {
58    let mut lines = Vec::new();
59    lines.push(format!(
60        "Compliance ({})",
61        result.level.name()
62    ));
63    lines.push(format!(
64        "Status: {} ({} errors, {} warnings, {} info)",
65        if result.is_compliant {
66            "COMPLIANT"
67        } else {
68            "NON-COMPLIANT"
69        },
70        result.error_count,
71        result.warning_count,
72        result.info_count
73    ));
74    lines.push(String::new());
75
76    if result.violations.is_empty() {
77        lines.push("No violations found.".to_string());
78        return lines.join("\n");
79    }
80
81    for v in &result.violations {
82        let severity = match v.severity {
83            ViolationSeverity::Error => "ERROR",
84            ViolationSeverity::Warning => "WARN",
85            ViolationSeverity::Info => "INFO",
86        };
87        let element = v.element.as_deref().unwrap_or("-");
88        lines.push(format!(
89            "[{}] {} | {} | {}",
90            severity,
91            v.category.name(),
92            v.requirement,
93            element
94        ));
95        lines.push(format!("  {}", v.message));
96    }
97
98    lines.join("\n")
99}
100
101/// Validate SBOM against NTIA minimum elements
102#[allow(clippy::unnecessary_wraps)]
103pub fn validate_ntia_elements(sbom: &NormalizedSbom) -> Result<()> {
104    let mut issues = Vec::new();
105
106    // Check document-level requirements
107    if sbom.document.creators.is_empty() {
108        issues.push("Missing author/creator information");
109    }
110
111    // Check component-level requirements
112    for (_id, comp) in &sbom.components {
113        if comp.name.is_empty() {
114            issues.push("Component missing name");
115        }
116        if comp.version.is_none() {
117            tracing::warn!("Component '{}' missing version", comp.name);
118        }
119        if comp.supplier.is_none() {
120            tracing::warn!("Component '{}' missing supplier", comp.name);
121        }
122        if comp.identifiers.purl.is_none()
123            && comp.identifiers.cpe.is_empty()
124            && comp.identifiers.swid.is_none()
125        {
126            tracing::warn!(
127                "Component '{}' missing unique identifier (PURL/CPE/SWID)",
128                comp.name
129            );
130        }
131    }
132
133    if sbom.edges.is_empty() && sbom.component_count() > 1 {
134        issues.push("Missing dependency relationships");
135    }
136
137    if issues.is_empty() {
138        tracing::info!("SBOM passes NTIA minimum elements validation");
139        println!("NTIA Validation: PASSED");
140    } else {
141        tracing::warn!("SBOM has {} NTIA validation issues", issues.len());
142        println!("NTIA Validation: FAILED");
143        for issue in &issues {
144            println!("  - {issue}");
145        }
146    }
147
148    Ok(())
149}
150
151/// FDA validation issue severity
152#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
153enum FdaSeverity {
154    Error,   // Must fix - will likely cause FDA rejection
155    Warning, // Should fix - may cause FDA questions
156    Info,    // Recommendation for improvement
157}
158
159impl std::fmt::Display for FdaSeverity {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            Self::Error => write!(f, "ERROR"),
163            Self::Warning => write!(f, "WARNING"),
164            Self::Info => write!(f, "INFO"),
165        }
166    }
167}
168
169/// FDA validation issue
170struct FdaIssue {
171    severity: FdaSeverity,
172    category: &'static str,
173    message: String,
174}
175
176/// Validate SBOM against FDA medical device requirements
177#[allow(clippy::unnecessary_wraps)]
178pub fn validate_fda_elements(sbom: &NormalizedSbom) -> Result<()> {
179    let mut issues: Vec<FdaIssue> = Vec::new();
180
181    // Document-level requirements
182    validate_fda_document(sbom, &mut issues);
183
184    // Component-level requirements
185    let component_stats = validate_fda_components(sbom, &mut issues);
186
187    // Relationship requirements
188    validate_fda_relationships(sbom, &mut issues);
189
190    // Vulnerability information
191    validate_fda_vulnerabilities(sbom, &mut issues);
192
193    // Output results
194    output_fda_results(sbom, &mut issues, &component_stats);
195
196    Ok(())
197}
198
199/// Component validation statistics
200struct ComponentStats {
201    total: usize,
202    without_version: usize,
203    without_supplier: usize,
204    without_hash: usize,
205    without_strong_hash: usize,
206    without_identifier: usize,
207    without_support_info: usize,
208}
209
210fn validate_fda_document(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
211    // Manufacturer/Author Information
212    if sbom.document.creators.is_empty() {
213        issues.push(FdaIssue {
214            severity: FdaSeverity::Error,
215            category: "Document",
216            message: "Missing SBOM author/manufacturer information".to_string(),
217        });
218    } else {
219        let has_org = sbom
220            .document
221            .creators
222            .iter()
223            .any(|c| c.creator_type == CreatorType::Organization);
224        if !has_org {
225            issues.push(FdaIssue {
226                severity: FdaSeverity::Warning,
227                category: "Document",
228                message: "No organization/manufacturer listed as SBOM creator".to_string(),
229            });
230        }
231
232        let has_contact = sbom.document.creators.iter().any(|c| c.email.is_some());
233        if !has_contact {
234            issues.push(FdaIssue {
235                severity: FdaSeverity::Warning,
236                category: "Document",
237                message: "No contact email provided for SBOM creators".to_string(),
238            });
239        }
240    }
241
242    // SBOM Name/Title
243    if sbom.document.name.is_none() {
244        issues.push(FdaIssue {
245            severity: FdaSeverity::Warning,
246            category: "Document",
247            message: "Missing SBOM document name/title".to_string(),
248        });
249    }
250
251    // Serial Number/Namespace
252    if sbom.document.serial_number.is_none() {
253        issues.push(FdaIssue {
254            severity: FdaSeverity::Warning,
255            category: "Document",
256            message: "Missing SBOM serial number or document namespace".to_string(),
257        });
258    }
259}
260
261fn validate_fda_components(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) -> ComponentStats {
262    let mut stats = ComponentStats {
263        total: sbom.component_count(),
264        without_version: 0,
265        without_supplier: 0,
266        without_hash: 0,
267        without_strong_hash: 0,
268        without_identifier: 0,
269        without_support_info: 0,
270    };
271
272    for (_id, comp) in &sbom.components {
273        if comp.name.is_empty() {
274            issues.push(FdaIssue {
275                severity: FdaSeverity::Error,
276                category: "Component",
277                message: "Component has empty name".to_string(),
278            });
279        }
280
281        if comp.version.is_none() {
282            stats.without_version += 1;
283        }
284
285        if comp.supplier.is_none() {
286            stats.without_supplier += 1;
287        }
288
289        if comp.hashes.is_empty() {
290            stats.without_hash += 1;
291        } else {
292            let has_strong_hash = comp.hashes.iter().any(|h| {
293                matches!(
294                    h.algorithm,
295                    HashAlgorithm::Sha256
296                        | HashAlgorithm::Sha384
297                        | HashAlgorithm::Sha512
298                        | HashAlgorithm::Sha3_256
299                        | HashAlgorithm::Sha3_384
300                        | HashAlgorithm::Sha3_512
301                        | HashAlgorithm::Blake2b256
302                        | HashAlgorithm::Blake2b384
303                        | HashAlgorithm::Blake2b512
304                        | HashAlgorithm::Blake3
305                )
306            });
307            if !has_strong_hash {
308                stats.without_strong_hash += 1;
309            }
310        }
311
312        if comp.identifiers.purl.is_none()
313            && comp.identifiers.cpe.is_empty()
314            && comp.identifiers.swid.is_none()
315        {
316            stats.without_identifier += 1;
317        }
318
319        let has_support_info = comp.external_refs.iter().any(|r| {
320            matches!(
321                r.ref_type,
322                ExternalRefType::Support
323                    | ExternalRefType::Website
324                    | ExternalRefType::SecurityContact
325                    | ExternalRefType::Advisories
326            )
327        });
328        if !has_support_info {
329            stats.without_support_info += 1;
330        }
331    }
332
333    // Add component issues
334    if stats.without_version > 0 {
335        issues.push(FdaIssue {
336            severity: FdaSeverity::Error,
337            category: "Component",
338            message: format!(
339                "{}/{} components missing version information",
340                stats.without_version, stats.total
341            ),
342        });
343    }
344
345    if stats.without_supplier > 0 {
346        issues.push(FdaIssue {
347            severity: FdaSeverity::Error,
348            category: "Component",
349            message: format!(
350                "{}/{} components missing supplier/manufacturer information",
351                stats.without_supplier, stats.total
352            ),
353        });
354    }
355
356    if stats.without_hash > 0 {
357        issues.push(FdaIssue {
358            severity: FdaSeverity::Error,
359            category: "Component",
360            message: format!(
361                "{}/{} components missing cryptographic hash",
362                stats.without_hash, stats.total
363            ),
364        });
365    }
366
367    if stats.without_strong_hash > 0 {
368        issues.push(FdaIssue {
369            severity: FdaSeverity::Warning,
370            category: "Component",
371            message: format!(
372                "{}/{} components have only weak hash algorithms (MD5/SHA-1). FDA recommends SHA-256 or stronger",
373                stats.without_strong_hash, stats.total
374            ),
375        });
376    }
377
378    if stats.without_identifier > 0 {
379        issues.push(FdaIssue {
380            severity: FdaSeverity::Error,
381            category: "Component",
382            message: format!(
383                "{}/{} components missing unique identifier (PURL/CPE/SWID)",
384                stats.without_identifier, stats.total
385            ),
386        });
387    }
388
389    if stats.without_support_info > 0 && stats.total > 0 {
390        let percentage = (stats.without_support_info as f64 / stats.total as f64) * 100.0;
391        if percentage > 50.0 {
392            issues.push(FdaIssue {
393                severity: FdaSeverity::Info,
394                category: "Component",
395                message: format!(
396                    "{}/{} components ({:.0}%) lack support/contact information",
397                    stats.without_support_info, stats.total, percentage
398                ),
399            });
400        }
401    }
402
403    stats
404}
405
406fn validate_fda_relationships(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
407    let total = sbom.component_count();
408
409    if sbom.edges.is_empty() && total > 1 {
410        issues.push(FdaIssue {
411            severity: FdaSeverity::Error,
412            category: "Dependency",
413            message: format!(
414                "No dependency relationships defined for {total} components"
415            ),
416        });
417    }
418
419    // Check for orphan components
420    if !sbom.edges.is_empty() {
421        let mut connected: HashSet<String> = HashSet::new();
422        for edge in &sbom.edges {
423            connected.insert(edge.from.value().to_string());
424            connected.insert(edge.to.value().to_string());
425        }
426        let orphan_count = sbom
427            .components
428            .keys()
429            .filter(|id| !connected.contains(id.value()))
430            .count();
431
432        if orphan_count > 0 && orphan_count < total {
433            issues.push(FdaIssue {
434                severity: FdaSeverity::Warning,
435                category: "Dependency",
436                message: format!(
437                    "{orphan_count}/{total} components have no dependency relationships (orphaned)"
438                ),
439            });
440        }
441    }
442}
443
444fn validate_fda_vulnerabilities(sbom: &NormalizedSbom, issues: &mut Vec<FdaIssue>) {
445    let vuln_info = sbom.all_vulnerabilities();
446    if !vuln_info.is_empty() {
447        let critical_vulns = vuln_info
448            .iter()
449            .filter(|(_, v)| matches!(v.severity, Some(Severity::Critical)))
450            .count();
451        let high_vulns = vuln_info
452            .iter()
453            .filter(|(_, v)| matches!(v.severity, Some(Severity::High)))
454            .count();
455
456        if critical_vulns > 0 || high_vulns > 0 {
457            issues.push(FdaIssue {
458                severity: FdaSeverity::Warning,
459                category: "Security",
460                message: format!(
461                    "SBOM contains {critical_vulns} critical and {high_vulns} high severity vulnerabilities"
462                ),
463            });
464        }
465    }
466}
467
468fn output_fda_results(sbom: &NormalizedSbom, issues: &mut [FdaIssue], _stats: &ComponentStats) {
469    // Sort issues by severity
470    issues.sort_by(|a, b| a.severity.cmp(&b.severity));
471
472    let error_count = issues
473        .iter()
474        .filter(|i| i.severity == FdaSeverity::Error)
475        .count();
476    let warning_count = issues
477        .iter()
478        .filter(|i| i.severity == FdaSeverity::Warning)
479        .count();
480    let info_count = issues
481        .iter()
482        .filter(|i| i.severity == FdaSeverity::Info)
483        .count();
484
485    // Print header
486    println!();
487    println!("===================================================================");
488    println!("  FDA Medical Device SBOM Validation Report");
489    println!("===================================================================");
490    println!();
491
492    // Print summary
493    println!(
494        "SBOM: {}",
495        sbom.document.name.as_deref().unwrap_or("(unnamed)")
496    );
497    println!(
498        "Format: {} {}",
499        sbom.document.format, sbom.document.format_version
500    );
501    println!("Components: {}", sbom.component_count());
502    println!("Dependencies: {}", sbom.edges.len());
503    println!();
504
505    // Print issues
506    if issues.is_empty() {
507        println!("PASSED - SBOM meets FDA premarket submission requirements");
508        println!();
509    } else {
510        if error_count > 0 {
511            println!(
512                "FAILED - {error_count} error(s), {warning_count} warning(s), {info_count} info"
513            );
514        } else {
515            println!(
516                "PASSED with warnings - {warning_count} warning(s), {info_count} info"
517            );
518        }
519        println!();
520
521        // Group by category
522        let categories: Vec<&str> = issues
523            .iter()
524            .map(|i| i.category)
525            .collect::<HashSet<_>>()
526            .into_iter()
527            .collect();
528
529        for category in categories {
530            println!("--- {category} ---");
531            for issue in issues.iter().filter(|i| i.category == category) {
532                let symbol = match issue.severity {
533                    FdaSeverity::Error => "X",
534                    FdaSeverity::Warning => "!",
535                    FdaSeverity::Info => "i",
536                };
537                println!("  {} [{}] {}", symbol, issue.severity, issue.message);
538            }
539            println!();
540        }
541    }
542
543    // Print FDA reference
544    println!("-------------------------------------------------------------------");
545    println!("Reference: FDA \"Cybersecurity in Medical Devices\" Guidance (2023)");
546    println!();
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_fda_severity_order() {
555        assert!(FdaSeverity::Error < FdaSeverity::Warning);
556        assert!(FdaSeverity::Warning < FdaSeverity::Info);
557    }
558
559    #[test]
560    fn test_fda_severity_display() {
561        assert_eq!(format!("{}", FdaSeverity::Error), "ERROR");
562        assert_eq!(format!("{}", FdaSeverity::Warning), "WARNING");
563        assert_eq!(format!("{}", FdaSeverity::Info), "INFO");
564    }
565
566    #[test]
567    fn test_validate_empty_sbom() {
568        let sbom = NormalizedSbom::default();
569        // Should not panic
570        let _ = validate_ntia_elements(&sbom);
571    }
572
573    #[test]
574    fn test_fda_document_validation() {
575        let sbom = NormalizedSbom::default();
576        let mut issues = Vec::new();
577        validate_fda_document(&sbom, &mut issues);
578        // Should find missing creator issue
579        assert!(!issues.is_empty());
580    }
581}