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